← jason fetzerShift Investigatordocs
Back to the investigatorGolden evalsSynthetic data / read-only tools

Agent contract and teaching workbench

Purpose and trigger

A user question wakes one bounded investigation. The agent requests evidence, optionally charts it, and returns a report. This is a learning/demo agent over synthetic records, with no scheduling or equipment authority.

Current execution mode

The default OfflinePlanner is rule-based and understands only supported patterns. The Anthropic analyst plans once: its JSON plan is checked against the same tool allowlist and scope rules before any step runs, and a plan with one step outside them is refused whole (plan_rejected). Optional OpenAIPlanner implements native Responses function calling through the same dispatcher; see OPENAI_BYOK.md. AnthropicPlanner implements the same Planner contract over the Messages API with tool_use blocks and runs the hosted demo with a server-held key behind per-IP and daily round guards; see ANTHROPIC_DEMO.md. PeriodReports is a separate deterministic aggregation path. Live quality is scored by the golden set's live cases; the Anthropic recordings replay in CI, the OpenAI ones are not recorded.

The OpenAI adapter implements Planner.next_step(context), returning either a tool request or a final list of evidence IDs. The application validates both. The answer renderer retains deterministic calculated evidence. After successful daily investigation, the OpenAI adapter can write a bounded narrative with fact placeholders. Application code validates field references and substitutes their values. It labels prose as model interpretation; schema/reference checks do not prove its meaning.

Context and memory

Application-owned production day, selected shift, a fixed process reference, available tool schemas and at most four recent question/scope entries accompany current-run evidence. Generated data and hidden scenario answers are not put into the prompt. Changing the day or dataset version clears incompatible history. Tool evidence IDs exist only for one investigation; a chart cannot reference another session/run's ID.

No conversation or subscriber information is persisted. The local server retains at most 32 in-memory contexts and three loaded day/scenario datasets. Browser controls own date/shift selection; the follow-up parser preserves the selected day. Ambiguous date-range questions fail explicitly in offline mode.

Tools and permissions

  • get_shift_kpis: output, gross area, downtime by kind (and maintenance plus operator as op_maint_pct), waste (dry-end over the yield after trim and shear, yield_sqft), throughput and lineal per setup/run; each banded figure carries the owner's band (<measure>_band, <measure>_band_text; issue #101).
  • get_downtime_breakdown: allowed kind filter and grouping by reason, place or kind, with stops and minutes per stop; external downtime (a power outage, kind External, external) and then planned maintenance (scheduled) sort last, after the unplanned maintenance and operator reasons. The model's kind filter is Maintenance, Operator or PM; external stops are read unfiltered. On a record-store day, flags: the shift's maintenance or operator downtime over its band, with the supervisor slot and the leading reason's recurrence.
  • get_wet_end_performance: actual elapsed-time speed versus grade-specific target, and running speed (stops out) versus the grade's trailing-year baseline (baseline_fpm, baseline_gap_ft, under_baseline; runs_under_baseline on the envelope).
  • get_quality_breakdown: rejected area by the expanded warp, bond and misalignment observation categories.
  • get_shift_overview: KPI row plus largest losses and three chart evidence components from fixed queries.
  • get_order_matrix: dimensions, outs, cuts, requested/planned/produced sheets and remaining quantities per fragment (the record families carry no order ledger, so requested and remaining sheets are null).
  • render_chart: supported bar or line specification referencing a successful result from this run.

Period tools read the whole record store (the rolling year, Monday to Friday) instead of the selected day. Each takes a window of {"period": "day|week|month|fiscal", "anchor": "YYYY-MM-DD"} (fiscal = the 4-4-5 accounting period; KPI envelopes flag unequal production-day counts) — week is the Mon–Fri production week containing the anchor, month the calendar month, and the previous window is computed the same way — plus an optional shift (null = all three shifts; under Selected shift only the selected shift is enforced in application code). All are read-only, bounded (fixed queries, bound values, 1,000-row limit, two-second interrupt), and return window, previous_window, coverage, source_rows and rows whose percentages are ratios of sums (METRICS.md).

  • get_period_kpis(window, compare, shift): the dashboard tiles with current, previous, delta, better direction and a change label; deltas withheld unless both windows are complete.
  • compare_periods(current_window, previous_window, shift): the same table for two arbitrary windows.
  • get_downtime_pareto(window, group_by reason|place|kind, measure minutes|count, shift): ordered with share and cumulative share, previous-window minutes alongside.
  • get_waste_breakdown(window, shift): trim, shear, bond, warp, misalignment, moisture, bale and unexplained (plus details: reported sub-reasons with share of dry-end) as sq ft and % of gross, with the previous window; the dry-end families and their total also as % of the yield after trim and shear (pct_of_yield, issue #101), and trim, shear and dry-end with the owner's band (band, band_text).
  • get_stops_profile(window, shift): stops, minutes and minutes per stop per production day plus the Mon–Fri weekday profile.
  • get_trend(metric, weeks 1–26, anchor, shift): weekly series of one allow-listed metric.
  • get_grade_totals(window, shift, measure volume|speed): per board grade, setups, wet ends, lineal ft, volume MSF and share, speed without stops and elapsed speed from the setup clocks, % of grade target, allocated stops and minutes, lineal per setup and per paper change, trim and dry-end %, previous window's volume. measure chooses only the lead and the chart, never the rows: volume (MSF beside the previous window, the default) or speed (ft/min without stops against each grade target).
  • get_overrun(window, shift): nominal versus actual cuts per knife (cuts, sheets, % of nominal) with the share of customer lines inside, over and under the ±2 % band; last row all knives.
  • get_rollstock(window, shift): paper consumed from the wet-end (paper run) records: lineal feet and MSF per board grade and web width, paper runs and feet per run, previous window's feet, MSF and runs with deltas. MSF is the paper footprint (feet × width / 12 / 1,000); the records carry no basis weight, so no tons. All shifts count whole runs on the production day they started; one shift counts the setups it ran on each run, so a run spanning two shifts is in both.
  • get_shift_dollars(shift): each downtime reason, the speed gap to 675 ft/min, side trim, shear and each dry-end reason priced at the assumed rates (paper, machine time, per year; contribution null with 'margin not set' until a margin is set), the recorded losses ranked by dollars; planned maintenance and a power outage carry apart (schedule-driven, external), no rank and no part of the recorded-losses total, and all_in_usd adds them (docs/COST_MODEL.md).
  • get_day_dollars(shifts): the same gaps summed over the listed shifts that are complete (all three when omitted) and priced once on the day (an incomplete shift is named in excluded_shifts, not added); the offline planner adds it, with the answer's shifts, to a dollars answer over more than one shift for the card's total. Not in a model's catalog and not on the analyst's plan allowlist, and the scope check refuses it for a shift outside the selected scope (issue #31b review).
  • get_opportunities(window, shift, lens): the levers ranked in $/yr (the speed of each run under its grade's trailing-year speed, trim over target, each downtime reason, shear, each dry-end reason), each with the records behind it and marked controllable, schedule-driven or external (a power outage); ranks levers, prescribes no adjustment. lens: all (default), supervisor (the controllable losses ranked, schedule-driven then external after), ownership (the total cost of waste, side trim first, the dry-end kinds as one tag-scrap subtotal with each kind beneath it).
  • get_downtime_review(window, shift): the owner's downtime answer (issue #101): maintenance and operator downtime against their bands as % of scheduled minutes; every reason by kind ranked by % of scheduled minutes with stops and minutes per stop, external downtime (a power outage, kind External) apart and planned maintenance last as scheduled time, neither in a band; flags: each shift over a band with its supervisor slot and the leading reason's recurrence over the trailing year. Offered to the Anthropic loop and the analyst's plan; the OpenAI loop leaves it out for its byte cap. Neither money tool is offered to a live planner yet (the Anthropic tool loop and plan catalog, the OpenAI loop); the offline router plans them.
  • get_speed_opportunity(window, slice_by width|crew, shift): per grade and width (or crew), typical speed without stops vs the best fifth of setups, % of target for both, gap, opportunity hours, dry-end % of the fast fifth vs the rest; short setups excluded and counted; no temperatures modelled.
  • get_speed_variance(window, shift, against baseline|previous|shift, against_shift): the gap between a reference speed and the window's speed with stops in ft/min and lineal ft, every grade held at its trailing-year baseline (issue #101), split into stops (unplanned), planned maintenance and external stops (each its own bucket when either side has one), setups (order changes), running under the grade's baseline and grade mix, which add up to it; details splits each bucket by stop reason or grade; each bucket is marked controllable, schedule-driven (the grade mix, planned maintenance) or external, and each controllable loss bucket is priced as machine time, the others never (docs/METRICS.md, Speed variance). against_shift is another shift, so the selected-shift scope refuses it. Offered to the Anthropic loop and the analyst's plan; the OpenAI loop leaves it out for its byte cap.

Forbidden: shell, arbitrary SQL/code, external URLs, filesystem browsing, machinery control, schedule changes, subscriber access or email sending. SQL values are bound separately and grouping identifiers are allowlisted.

Bounds, failures and done

Default offline bounds: 20 tool calls and a ten-second loop deadline checked between planner steps. The server configures live mode for ten tool calls and 60 seconds; the adapter also caps model calls at ten and uses bounded socket timeouts. Agent context is limited to 200,000 serialized characters; the live serialized request limit is 24 KB for the OpenAI adapter and 64 KB for the Anthropic adapter, which trims the oldest tool results to a stub before refusing a request. DuckDB queries use a two-second interrupt timer and a 1,000-row limit. Failed tool validation consumes a call. Period aggregation bypasses the agent loop and is bounded by the fixed demo calendar rather than these loop deadlines.

These are local-demo controls. The synchronous planner deadline does not preempt a blocked third-party adapter; any future adapter must enforce its own HTTP timeout, token/output limits and an application-wide spending reservation before making calls. Offline mode makes no model calls. Explicit OpenAI mode reserves spending before each request; no real API calls have been made during automated validation.

Intended done condition: supported analysis has successful referenced evidence and any requested charts. Finish validates successful evidence IDs. Explicit chart/graph/plot requests without a chart return partial, and overview evidence automatically supplies its three charts. Semantic fulfillment is not guaranteed. See review R4. Incomplete data must be disclosed and must not be ranked as valid performance. A daily run can be complete while its evidence coverage is incomplete. Period status is partial when its window has missing/invalid contributions. A recorded symptom does not establish a mechanical cause.

Answer shapes

An offline answer's sections are a shape per intent (issue #75, shapes.py), built from the tool rows the answer already has; no new number is computed, only rows chosen (the largest reason, the slowest run, the three slowest grades) and counted. They stay plain text with two markers, so the Markdown export, the PDF and /evals read them unchanged:

  • Headline: the first section with a **figure** that is not a bullet. The figure is the asked measure with its unit (speed against grade target for "why was this shift slow", the slowest grade for "what grades ran slowest", minutes at the top reason for a Pareto); the rest of the line is what it is held against: a target, the previous window, the other shifts.
  • Findings: - bullets right under it, three to five for a diagnosis, each with exactly one **figure** and its evidence (query N · row M): step N of the trace, as the query box numbers it, and the row of that result the figure is a value of (detail M, weekday M and flag M name a result's sub-reason, weekday and flag lists; no row, a count of the result's rows or a total its envelope carries). A list of like items longer than three is bullets or a table, never one sentence; the client shows five findings per card and folds the rest under one "more".
  • Labels: a short line without digits or final punctuation introduces the bullets after it (Reported sub-reasons, Most downtime, Weekday profile) and renders in small caps.
  • Where to look: two or three bullets after the label Where to look, each naming a place in the records, never an action, and ending with the question that opens it, → “Show the setup matrix”. Each is a question the offline router answers in the same scope; the client links it to that question's address.
  • Anything else is a plain sentence of at most about forty words: the window and its coverage, a tool's note split into its sentences, a caveat, a reported note. After the findings the client sets these under the charts, so the chart follows the findings directly; a data-quality warning stays with the findings.
  • Every number on a line that cites a row is a value of that row (or a count of the result's rows, or a rule constant): a line never states a figure from a row it does not cite, and a rank ("the lowest of the three shifts", "the highest of 16 runs") is said of the figure the row was chosen by. The chart for the answer's measure comes first: a speed-led answer (why slow, compare) shows speed against grade target, then the downtime that explains it.

Money (issue #31) takes the same shape. Where the money went this shift is a headline total with its per-year figure, then each gap ranked by dollars as the tool ranked it. Where the money is over a window is the levers' total a year, then each lever with the records behind it. A priced row elsewhere (a downtime reason, a reject reason, a Pareto row, a waste row, a speed-opportunity cell) carries its dollars and per-year figure beside its quantity on the same bullet. The rates line, the cost note, the contribution status and one row's pricing formula, quoted as the cost model wrote it, read as caveats under the charts.

An incomplete window (coverage other than complete) is listed, never ranked: no "Slowest", "the largest" or "the most of", its rows in the tool's order, and one line saying rankings are withheld; a daily shift with excluded records was already reported and never ranked. A tool called twice for a shift (downtime by reason, then by place) keeps a finding for each call.

A shape depends on the plan that ran and the scope, never on the wording of the question, so a question and its canonical address answer with the same sections (URLS); the period intents the URL grammar resolves as aliases (a day-shaped downtime or quality question under a ranged window, and dollars over a window) share the Pareto's, the waste breakdown's and the levers' shape. An analyst's own plan is shaped by the tools it ran, and every other result keeps its own finding, so the calculated evidence under a narrative never drops a result it can cite. The evals hold the shape from outside: every bold figure must be a value of the row it cites (evals/README.md, global invariants).

Interface and transport

One screen offers date/shift scope, questions, calculated metrics, charts, report download and expandable tool/context/SQL traces. It labels synthetic data and offline planning. No fabricated email signup/success flow exists.

By default the server binds only to 127.0.0.1. It enforces Host, Origin, a random request token, JSON content type, 16 KB request bodies and a shared 60-requests/minute limit. Static files have an explicit allowlist and a restrictive content security policy. Labels are rendered as text, not HTML. The hosted demo runs the same single process in public mode (below, and deployment); it is a demo, not a multi-tenant service.

Optional WebMCP registration invokes the same visible investigation action and validates its question argument. Browsers without support use the ordinary interface. Browser-level WebMCP execution has not been verified; no claim of that validation is made.

Tests and limits

Tests exercise a full investigation, follow-up scope, all-shift comparison, exact chart/evidence equality, missing evidence, forbidden tools, call/deadline limits, incomplete records, history retention, date changes and HTTP protections. Unit/HTTP tests and JavaScript syntax checks do not replace visual browser QA or live-model evaluation.

Still pending: live-provider validation on the current dataset (the Anthropic recordings are stale until re-recorded), public service-wide budget infrastructure, email provider and verified subscriptions, plant timezone/DST, overlapping-stop and cross-shift allocation, production security/deployment and broader scenario calibration. The implementation follow-up on the repaired PDF/history/guidance defects is in the archived review, archive/REVIEW_2026-09-07.md; semantic evaluation is the golden set's job now (evals/README.md).

Evidence-reporting extension

New read tools: get_setup_matrix(shift), get_shift_notes(shift), get_rankings(shift, metric). Metric is restricted to speed/downtime/quality. Notes are bounded to 1,000 characters, passed as untrusted evidence, and rendered with text nodes or escaped PDF paragraphs. They cannot expand tools or shift scope. Selected-shift mode is enforced in application code even for a malicious model call. Daily OpenAI matrix results show the model at most 12 preview rows with explicit disclosure; the UI retains the full validated matrix.

The Period control is part of the scope. Under Week, Month or 4-4-5 the agent passes the selected window to the planner (Context.period; the offline planner uses it as the default window and turns day-shaped questions into their period form) and refuses any daily tool with the window to use instead; a window the question names still wins. So do the shift and the relative window a question names (issue #105): one named shift under Selected shift only, and yesterday, last week, last month or last period, move the context to that scope before the router parses the question or the analyst plans (agent.question_scope); the analyst is told so in one sentence of its prompt and is held to that scope by the same plan check. A comparison of other shifts still asks for the production-day scope (URLS). Week, calendar-month and 4-4-5 reports are a separate deterministic job triggered by the Period report button (report: true on /api/ask). It aggregates validated per-day SQL results for the bounded Monday–Friday calendar of the record store (the rolling year), returning sections, tables, heatmaps, charts, coverage and dataset hashes. No model/API permission exists on this path. At most 60 compact day/scenario snapshots are cached, with no live DB connection retained. These reports do not pretend to understand arbitrary free-text range questions. Period comparison requires both windows to be complete; no interpolation or zero-fill is used. Paper-change totals count distinct wet-end IDs within each day and shift, then sum across the period. The synthetic year uses September 1, 2026 as its provisional 4-4-5 anchor pending owner confirmation.

The offline router gives measure words precedence (issue #74): a speed word (speed, fpm, ft/min, fast, pace, slow/slower; "running" only beside one of them) or a volume word (throughput, output, volume, made, produced, MSF, lineal, footage) decides the intent, and a grouping word (by grade, by width, by crew) only chooses the breakdown, so "fpm by grade" is speed against each grade target (measure=fpm&by=grade), never volume. Both parse paths, the day path (_parse_request) and the period path (parse_period_request), take that decision from one function, agent.speed_or_volume, so a phrasing cannot route one way with no window and another way with "this week": the two paths diverging is where the next routing bug comes from, so a new speed or volume word goes in that function and nowhere else. A volume cue attached to a volume ask ("how much did we make", "ran the most", "share of output") beside a speed word keeps the question volume; a loose "most", "share" or "how much" does not ("which shift lost the most speed" is speed), but a bare volume word does not veto a speed word ("did we make our speed target this week" and "speed and output" are speed), a loss word (stops, downtime, waste, trim, rejects, quality) keeps a "slow" question on the losses, and a speed word that asks how fast a paper change, changeover or setup went ("how fast did we change paper", "how quick were the changeovers") is a setup question, the setup matrix (measure=setups), never line speed, while a line-speed question that only mentions one stays speed ("fpm by grade after changeovers", "how fast are we running between changeovers") and a diagnosis stays the overview ("why was this shift slow on changeovers"). Like every setup-matrix question it is refused under the Week, Month or 4-4-5 control, and with a window named in the question ("… this week") it gets that window's KPI tiles, which count setups and paper changes. A speed question over a named window ("average speed this week", "our speed vs last week", "how fast did we run in August") runs the period speed plan, a volume question over one ("throughput this week") volume by grade; a diagnosis cue (why … slow, slow, bad, killed) stays on the overview form at every grain ("why was this shift slow this week" is measure=overview&window=week), never a speed plan alone, and a grade word does not override it ("why was 275-BC slow this week"); the one exception is "slower than normal", which reads the weekly speed trend until the baselines of #76 exist (its golden, speed-slower-than-normal, carries a note saying so); two named months stay on compare_periods.

Offline questions are URL-addressable (URLS): GET /q resolves a question with the offline router and redirects to its canonical address, which answers as .json or as a PDF with format=pdf; that path never calls a model, carries no key or token, keeps no session (follow-up context is the after= chain in the address) and queues on its own lock, never behind a live round. Every routing golden records its address as expect.url. The server retains at most 32 latest live investigation reports for PDF export. The /api/report POST accepts only session_id and run_id; PDF layout is trusted application code, not submitted HTML. ReportLab is the only added application dependency. PDF export neither sends email nor incurs model cost. Host, Origin, token, request-size and rate checks apply to both POST routes. By default this is a loopback demonstration; the hosted public-demo mode (docs/DEPLOYMENT.md) swaps the loopback Host rule for an Origin allowlist, serializes investigations under a lock, and never accepts visitor keys. It is still a demo, not a multi-tenant service.

Rendered from docs/AGENT_CONTRACT.md in the repository.