# Agent input observability — design

**Plan slug:** `agent-input-observability`  
**Date:** 2026-08-17  
**Owner-visible evidence first:** In `/factory`, opening a real agent attempt shows the full work request without requiring the owner to decode `prompt`. The view also states what was added on that dispatch, what was inherited from the reused model session, and every explicitly supplied file or handoff with its real delivery mode. Missing coverage is visible, never inferred away.

## Problem

The attempt detail currently records a rendered system prompt, rendered user prompt, and launch command, then collapses each behind generic forensic labels. The actual work is nested beneath the template variable `prompt`, while the visible task sentence merely says to implement the work described there. Previous envelopes and a context-directory path are mixed into the same message. There is no reliable accounting of files, images, or inherited session context.

Stored bytes exist, but the owner cannot answer the basic question: **what task and inputs did this agent actually have?**

## Decisions

1. Make the stable **task instructions** a first-class attempt field and show them open by default.
2. Trace a versioned **input packet** for every attempt. The packet describes current-dispatch additions and its coverage; it does not pretend to contain the whole model context.
3. Preserve **session lineage** so every later turn discloses inherited prior turns, and distinguish a previous turn from a retry relationship.
4. Distinguish delivery modes:
   - `inline` — bytes included in the current user/system message;
   - `session_history` — content inherited from the reused model session;
   - `path_reference` — a filesystem path made available for an agent tool to read;
   - `provider_attachment` — bytes attached through a provider-supported attachment API.
5. Snapshot explicitly referenced files for observability, while stating whether the model received bytes or only a readable path.
6. Keep exact system/user messages as secondary forensic evidence.
7. Deliver in two owner-visible slices. Slice 1 fixes task blindness with existing trace data. Slice 2 adds complete typed accounting and previews after producer capture exists.

“Explicit inputs” does not mean the entire readable repository. Workspace access remains observable through tool calls.

## Scope

### Included

- Stable task text, exact current message, send kind, attempt order, and retry lineage.
- Honest inherited-session indicator for reused Pi sessions.
- Previous-envelope and context-handoff inventory with explicit delivery mode.
- A typed request/source seam that preserves whether the CLI argument was inline text or a file path.
- Dispatch-time file snapshots with content identity and bounded capture.
- Future provider attachment descriptors using the same tracing contract.
- Collector and web contracts that tolerate old databases and partial rollout.
- One shared input UI used by both factory attempt-detail surfaces.
- Safe text and raster-image previews through contained artifact transport.

### Excluded

- Inventorying the whole repository.
- Calling files discovered by tool use pre-dispatch inputs.
- Retrofitting invented packet data onto historical attempts.
- Inline rendering of SVG, HTML, scripts, archives, office files, or unknown binaries.
- Adding provider attachment support; this work traces it when a provider supports it.
- Editing or deleting inputs from the observability UI.

## Owner-facing hierarchy

Both attempt-detail surfaces render the same domain component in this order:

1. **Task instructions** — open by default.
2. **This dispatch** — initial request, parse correction, or check correction; exact current message available.
3. **Inherited context** — prior attempt/turn lineage for the reused model session, or an honest coverage gap.
4. **Files available** — path references and snapshots.
5. **Attachments delivered** — only true `provider_attachment` items.
6. **Exact model messages** — system and user messages on demand.

A context-handoff file is never labeled “received” merely because its path was sent. Its row says `Available by path`. A previous envelope included in the message says `Included in message`. Provider bytes say `Attached to request`.

Historical attempts show:

> Task instructions were not captured separately for this attempt. The exact user message is available below.

Before typed packet capture exists, the view says:

> Attachment accounting was not recorded for this attempt.

An authoritative captured empty packet says:

> No explicit files or attachments were supplied on this dispatch.

## Producer contracts

### Resolved request

Replace provenance-destroying path resolution with a typed seam:

`resolve_request(arg: str) -> ResolvedRequest`

`ResolvedRequest` carries:

- exact task text;
- source kind `inline_text` or `prompt_file`;
- nullable source path;
- UTF-8 byte count and lowercase SHA-256 of the resolved task bytes.

Workflow entrypoints continue passing task text to `AgentCall`, but the request descriptor travels with it. Existing callers that construct `AgentCall` directly receive an explicit `inline_text` descriptor.

### Attempt task and lineage

Additive `agent_attempts` columns:

- `task_prompt TEXT` — stable original work request;
- `send_kind TEXT` — `initial`, `parse_retry`, or `gate_retry`;
- `turn_index INTEGER` — one-based order within the reused agent session;
- `previous_turn_attempt_id TEXT` — nullable immediately preceding turn in that reused session;
- `retry_of_attempt_id TEXT` — nullable prior attempt only when this turn retries/corrects that attempt;
- `input_packet_version INTEGER`;
- `input_coverage TEXT` — `complete`, `partial`, or `not_recorded`;
- `declared_input_count INTEGER` — nullable when bounded discovery stops before the total is knowable;
- `captured_input_count INTEGER`;
- `omitted_input_count INTEGER` — nullable when discovery was incomplete.

`user_prompt` remains the canonical exact current instruction. No duplicate `instruction_text` field is added.

A reused Pi session includes prior messages, model responses, and tool context that are not duplicated into the current packet. `turn_index` and `previous_turn_attempt_id` expose inheritance for every later turn; `retry_of_attempt_id` additionally marks only correction/retry turns. The UI links to prior recorded attempts; if provider-side history cannot be fully enumerated, it states that limitation rather than claiming complete model-context capture.

### Input-item schema

New table `agent_attempt_inputs`, keyed by `input_id`, with unique `(attempt_id, seq)`:

- `attempt_id`, `seq`, and owner-facing `label`;
- `source_kind`: `prompt_file`, `previous_envelope`, `context_handoff`, or `explicit_attachment`;
- `delivery_mode`: `inline`, `session_history`, `path_reference`, or `provider_attachment`;
- nullable original `source_path` and run-owned `snapshot_path`;
- `media_type` derived from validated content;
- `preview_kind`: `text`, `image`, or `none`;
- `size_bytes` and lowercase SHA-256 of captured bytes;
- `capture_status`: `captured`, `missing`, `unreadable`, `limit_exceeded`, `changed_during_capture`, `enumeration_incomplete`, or `not_applicable`;
- `artifact_status`: `available`, `evicted`, or `never_captured`;
- nullable safe `detail` and `captured_at`.

Media type, provenance, transport, preview support, capture success, and later byte retention remain orthogonal. A captured ZIP may have `preview_kind=none`; it is not an unsupported capture. An evicted snapshot retains provenance, hash, size, and the explicit reason its preview bytes are gone.

### Input packet seam

`capture_attempt_inputs(...) -> AgentInputPacket`

The packet contains version, coverage, declared/captured counts, task provenance, lineage, and ordered items.

Capture bounds are fixed constants and emitted in failure detail:

- at most 100 files;
- traversal depth at most 8 below a declared context root;
- at most 10 MiB per file;
- at most 50 MiB total snapshot bytes per attempt;
- at most 5 seconds of capture wall time measured with an injectable monotonic clock.

Behavior:

- preallocate the attempt ID and its snapshot directory;
- enumerate paths deterministically by normalized relative path;
- never follow symlinks;
- copy each source to a same-filesystem temporary file, hash and size that copy, re-check source identity/size/mtime, then atomically rename;
- delete temporary or changed-during-copy bytes before recording failure;
- stop traversal immediately when file-count, depth, byte, or time bounds make further discovery unsafe;
- append one `enumeration_incomplete` sentinel naming the tripped bound; leave declared/omitted totals unknown unless already provable, and set coverage to `partial`;
- write attempt and item rows in one SQLite transaction only after filesystem capture finishes;
- if the database transaction fails, remove the preallocated attempt snapshot directory;
- if cleanup itself fails, emit a run error naming the orphan path;
- on startup, age-gated reconciliation removes attempt snapshot directories older than the protection window when no committed attempt row owns them.

Observability capture must not change the model request. An unavailable snapshot produces a named gap and dispatch continues with the same input semantics.

### Disk-budget retention

Snapshot bytes are retained by allocated disk space, not age. Metadata, hashes, provenance, and coverage rows remain with the run.

- Configuration exposes a snapshot byte budget and a minimum-free-space floor on the factory state filesystem.
- Before capture and after each committed packet, reconciliation measures both facts. When either boundary is crossed, it evicts the oldest snapshot bytes belonging to terminal attempts until both are satisfied.
- Active/running attempts and newly staged uncommitted captures are protected.
- Eviction updates `artifact_status=evicted` and records the disk-budget reason; it never deletes an input row or changes the original capture status/hash/size.
- If protected bytes alone exceed the budget, new capture records `limit_exceeded`, packet coverage becomes partial, dispatch continues, and the condition is visible in the run.
- A per-run cap prevents one run from consuming the global allocation. Both limits are configuration, surfaced in coverage evidence, and tested at small injected values rather than hidden hardcoded workstation assumptions.
- Snapshot bytes have no independent time expiry. Explicit run deletion may remove its remaining bytes and metadata according to the run-deletion contract when that contract exists.

### Source-specific behavior

- **Prompt file:** `ResolvedRequest` owns the exact task bytes read once at request resolution. The initial send records those bytes as `source_kind=prompt_file`, `delivery_mode=inline`, and snapshots those same held bytes rather than rereading the mutable source. Later turns classify the task as `session_history`.
- **Previous envelope:** materialize canonical UTF-8 JSON from the held envelope. It is `delivery_mode=inline` on the send whose rendered message includes it and `session_history` only on later sends that inherit it.
- **Context handoff:** every declared regular file beneath the directory at dispatch is `delivery_mode=path_reference`; symlinks become unavailable rows and are not dereferenced.
- **Provider attachment:** one descriptor list must drive both provider request construction and tracing. No second invisible provider path is permitted.

## Trace persistence

`tracer.py` remains schema authority.

- New additive attempt columns go through `MIGRATIONS`; the shipped `CREATE TABLE agent_attempts` body is not edited.
- The new input table is added to the schema creation script so it is created for both new and existing databases when schema setup runs.
- A tracer method receives the preallocated attempt record and packet and commits both atomically.
- Existing `system_prompt` and `user_prompt` remain unchanged.

## Collector contracts

`collector/src/adapters/factory.ts` feature-detects every new column and table. Each attempt view adds:

- `taskPrompt`, `sendKind`, `turnIndex`, and `retryOfAttemptId`;
- `inputPacketVersion`, `inputCoverage`, declared/captured counts;
- ordered `inputs` with separate source, delivery, media, preview, and capture fields.

Absent schema maps to `inputCoverage='not_recorded'`, never to an authoritative empty list. Malformed rows remain visible as unavailable inputs with a safe reason instead of disappearing.

### Artifact transport

Text and raster previews resolve from authoritative `input_id` metadata; clients never supply a host path or authoritative hash. The contained factory artifact endpoint gains input-preview mode:

- collector looks up the committed input row, requires `artifact_status=available`, and resolves its snapshot beneath the allowlisted factory session root without following symlinks;
- expected SHA-256 comes from the row and is verified for text and images before response;
- text retains bounded paging, but every page is read only after whole-file integrity validation;
- maximum raster response is 10 MiB and range/tail behavior is disabled;
- magic-byte decode permits PNG, JPEG, GIF, or WebP only;
- response content type comes from validated bytes, not extension;
- response includes `X-Content-Type-Options: nosniff`, private `no-store` caching, and the integrity hash header;
- unknown, evicted, oversized, or hash-mismatched input IDs are refused.

The same-origin web proxy at `apps/web/src/pages/api/collector/[...path].ts` must preserve the allowlisted content type, length/range metadata, `nosniff`, cache, and integrity headers. Tests exercise the browser-facing proxy path, not merely the collector server. No host path becomes a direct browser URL.

## Shared web boundary

Both `FactoryAttemptDetails` and `FactoryStepDrawer` consume one domain component:

`AgentInputPanel({ attempt, priorTurns }: { attempt: FactoryAgentAttemptView; priorTurns: FactoryAgentAttemptView[] }): JSX.Element`

Each drawer caller receives the containing run’s attempts; no component searches across runs. `priorTurns` includes only strictly earlier attempts from that same run with the same non-null `sessionId`, ordered by started time and then attempt ID. Null session IDs are never grouped.

For a correction turn in Slice 1, the panel may recover a stable task only from the nearest strictly prior, same-phase attempt whose known-template extraction succeeds uniquely. Missing timestamps, equal/invalid ordering, no unique extraction, or phase mismatch is ambiguous. On ambiguity, show the exact current user message and state that the stable task or inherited context could not be separated; never infer that it was absent. Slice 2 consumes explicit lineage fields when available.

Responsibilities:

- apply the task/dispatch/inheritance/files/attachments hierarchy;
- render packet coverage and counts;
- group items by delivery mode without changing their order inside a group;
- use one shared artifact loader for text and image previews;
- show exact messages and legacy states consistently;
- render labels/paths as text only;
- provide keyboard-complete, focus-visible controls and bounded previews.

Existing Astryx and deck-ui controls are sufficient. No new generic primitive is authorized. The existing factory page keeps its current primitive layer; unrelated controls are not ported.

## Data flow

1. The CLI resolves inline text or a prompt file to `ResolvedRequest` without losing provenance.
2. The workflow creates `AgentCall` with stable task and request descriptor.
3. Before each provider attempt, the producer assigns lineage and captures the bounded packet.
4. Tracer persists attempt plus packet; provider launch proceeds with unchanged request bytes.
5. Reused-session retries point to the prior attempt and increment `turn_index`.
6. Collector loads typed coverage and inputs, tolerating legacy schema.
7. Shared web UI shows the stable task first, then current additions, inherited lineage, path-available files, true provider attachments, and exact messages.
8. Preview requests read only dispatch-time, hash-verified run-owned snapshots through contained transport.

## Failure behavior

- Schema migration failure remains fail-closed at factory startup.
- Capture failures never become silent omissions; coverage and item status expose them.
- Capture does not block the agent unless the original input contract itself is invalid.
- SQLite/filesystem atomicity is compensated explicitly through preallocation, atomic rename, transaction ordering, and cleanup.
- Collector parse or preview failures remain local to the affected item.
- Historical attempts are never reconstructed and labeled complete.
- Inherited model context is linked where recorded and labeled incomplete where provider history is opaque.

## Verification

### Slice 1 — task blindness removed

Uses existing trace fields only; no factory-producer change.

Focused web tests prove:

- each known initial factory template has an explicit extractor bounded by its `### prompt` heading and template-specific next terminal heading; the exact body appears as `Task instructions` open by default;
- extraction is explicitly labeled as derived from the exact user message;
- ambiguous, missing, repeated, or unknown boundaries fall back to the full exact user message rather than showing a false task;
- later attempts receive only run-scoped, strictly prior, same non-null-session turns; null session IDs, cross-run attempts, equal timestamps, phase mismatch, and ambiguous ordering never create inherited context;
- a correction recovers a stable task only from the nearest eligible same-phase turn with one successful extraction; otherwise the full current message and explicit ambiguity note render;
- Slice 1 does not guess whether a correction was a parse retry or check retry;
- attachment state says `not recorded`, never zero;
- both detail surfaces render through one `AgentInputPanel`.

Then run web static/build checks and an installed owner flow on a real historical/current run. Acceptance: the owner opens `/factory`, opens an attempt, and reads the real task immediately; attachment accounting is honestly identified as unavailable.

### Slice 2 producer lane — capture outside the factory

Focused factory tests prove typed request provenance, send classification, retry lineage, deterministic capture, each bound, symlink refusal, source mutation detection, cleanup behavior, packet coverage, and unchanged provider request bytes.

Because this changes factory attempt machinery, run:

`python3 -m pytest modules/harness/factory/tests/ -q`

This lane is implemented and committed by a non-factory agent before any factory consumer plan is launched.

### Slice 2 consumer lane — collector/web packet and previews

Only after the Slice 2 producer lane is committed, launch the factory against a collector/web-only plan that contains no writable path under `modules/harness/factory/**`.

Focused collector tests prove new and legacy schema mapping, coverage distinction, stable order, containment, raster magic validation, headers, size/hash refusal, and text paging.

Focused web tests prove task visibility, lineage, delivery-mode labels, complete/partial/not-recorded states, every item exactly once, safe text/image preview, no-preview binaries, unavailable reasons, and keyboard behavior.

Then run collector and web static/broad checks in the repository-prescribed order.

Installed acceptance uses only inputs the supported public factory entrypoint genuinely produces today: a prompt-file request and a later phase with a previous envelope. It must prove:

1. full task readable without opening forensic messages;
2. prompt-file provenance visible as inline task bytes on the initial turn and inherited history later;
3. previous-envelope delivery mode is truthful on the turn that includes it;
4. text snapshots open and match recorded SHA-256 through the browser-facing proxy;
5. exact system/user messages match tracer data;
6. a later turn links inherited context and preserves the stable task;
7. repository files are not mislabeled as inputs.

Context-handoff text/image path references and image rendering are integration-tested with the producer seam, but they are not claimed as installed owner-flow proof until a supported public factory input entrypoint can create them.

Completion requires this flow against installed `origin/main`.

## Delivery and sequencing

### Plan now: Slice 1 only

The first executable plan contains only the shared task-first web panel built from existing trace fields. It names no producer path and makes no attachment-completeness claim. Land, deploy, and prove it through the installed owner flow before authoring or launching any Slice 2 implementation plan. This is useful behavior, not enabling-only infrastructure.

### Plan later: Slice 2

After Slice 1 has deployed proof, author Slice 2 with fail-safe writer sequencing:

1. non-factory producer lane changes only `modules/harness/factory/**`, runs the mandatory full harness suite, and commits;
2. no factory run starts until that producer commit is the consumer workspace baseline;
3. factory receives a separate collector/web-only plan and cannot see producer paths as implementation tasks;
4. collector/web changes land with producer capture and installed owner-flow proof.

Never run producer and consumer writers concurrently in one workspace.

## Architecture Decisions

- **Accepted:** typed packet over parsing prompt prose as the final architecture. Prompt extraction exists only as the deployed Slice 1 bridge and is labeled derived/not-recorded.
- **Accepted:** stable task, current instruction, inherited session lineage, and exact messages are separate truths.
- **Accepted:** source kind, delivery mode, media type, preview capability, and capture status are orthogonal fields.
- **Accepted:** explicit coverage/version/counts distinguish empty from absent or partial recording.
- **Accepted:** bounded snapshot module. It passes the deletion test by containing traversal, race detection, limits, hashing, and cleanup that would otherwise scatter through orchestration.
- **Accepted:** one shared `AgentInputPanel` and artifact loader prevent the two existing detail surfaces from diverging.
- **Accepted:** snapshot path references for evidence while labeling them as path access, not delivered bytes.
- **Rejected:** inventory the entire readable workspace; capability is not attachment.
- **Rejected:** infer historical packets and call them complete.
- **Rejected:** duplicate `user_prompt` into another instruction field.
- **Rejected:** inline attachment bodies in the polled detail payload.
- **Rejected:** hand a producer-writing plan to the factory; its self-edit guard is load-bearing.
