Skip to content

agent: frame agent-derived fields in a per-message nonce envelope (#124, F3) - #195

Open
BaaRai wants to merge 1 commit into
mainfrom
feat/derived-field-envelope
Open

BaaRai wants to merge 1 commit into
mainfrom
feat/derived-field-envelope

Conversation

@BaaRai

@BaaRai BaaRai commented Nov 12, 2026

Copy link
Copy Markdown
Owner

What

Agent- and target-derived fields reach the planner and worker user messages inside a code-owned data envelope. Each message gets its own random nonce.

  • Planner triggers (renderTriggers): the worker output conclusion, the finding detail, and hints not written by the operator are framed. An operator hint (origin human) stays outside the frame, prefixed [operator].
  • Overview (renderOverviewSections, used by the planner situational block and the worker launch overview): the snapshot is split by origin.
    • The operator block sits outside the envelope: task (description and goal), goals (goal nodes), and hints written by the operator.
    • The counters, intents, stagnation warning, coverage and credentials stay plain JSON, as before.
    • Each of recent_facts, finding_list, cold_digests and related_tasks is framed. The hints of every other origin go in an agent_hints frame.
  • Constraints are unchanged. They stay in the system prompt and in the goal anchor that closes the planner message, both outside the envelope.
  • graphOverviewData now adds origin to each hint entry, so the split can tell operator hints from the others. The graph_overview tool output gains this field too.
  • The overview's recent_facts now use compactFact, so their summaries are capped.

Why

Audit F3 for #124 (memory poisoning and plan hijacking rows), target 0.7.0. Raw worker output and fact summaries reached the planner's user message without framing, and so did every worker overview. That includes a payload a worker quoted verbatim in a fact, as the #49 rule asks it to. The Norma boundary frames tool results only, never these messages.

Envelope format

[Data envelope (code-owned)]: this message's nonce is <N>. ... (once per message)

[[ARTEX_DATA nonce=<N> field=<field>]]
<content>
[[END_ARTEX_DATA nonce=<N>]]
  • <N> is 16 bytes from crypto/rand, hex-encoded (32 characters). A new one is drawn for each planner round message and each worker launch message. All the frames of one message share it.
  • The envelope and its intro are rendered in Go (agent/data_envelope.go), never in an editable prompt body. The intro describes the markers without spelling a complete one.
  • The markers avoid <, > and &, so json.Marshal never escapes them. A marker copied into a fact therefore stays literal in the JSON, and the neutraliser can find it.
  • Neutralisation: a fixed-string strings.Replacer rewrites every occurrence of the stem ARTEX_DATA (both markers contain it) to artex-data-marker-removed.
    • The replacement starts with a character that is not in the stem and contains no uppercase A. Rewritten text therefore cannot form a new stem with its neighbours.
    • The data cannot open or close a frame, even if it guesses the nonce.

Fact summary cap

The cap is factSummaryMax = 160 runes, plus an ellipsis and summary_truncated: true. This is the existing named constant that list_facts already applies.

  • A fact is meant to be a one-line conclusion, so the same fact now reads the same in list_facts and in the overview.
  • At 20 recent facts, it bounds fact text to about 3,200 runes per message, which limits how much text one fact can carry into every planner round and worker launch.
  • The full text stays one node_detail(id) call away.

Operator versus derived

Outside the envelope (operator) Inside the envelope (derived)
Task goal and description, goal nodes Trigger worker output, trigger finding detail
Operation constraints (system prompt and goal anchor) recent_facts, finding_list, cold_digests, related_tasks
Hints with origin human, labelled operator Hints with any other origin (orchestrator, worker-N, planner, unrecorded)

Goal nodes stay outside because they are the operator's goal, either set by the operator or decomposed from it. They are not target content.

Tests

The tests are pure and need no database (agent/data_envelope_test.go):

  • TestDataEnvelopeNeutralisesMarkers: content that tries to close the frame with the right nonce, then open a new one, is neutralised. Only the two code-owned markers survive, and the payload stays inside the frame. The same holds after JSON encoding in a fact summary.
  • TestDataEnvelopeNoncePerMessage: two messages get different nonces. Each nonce is at least 16 hex-decoded bytes, and every frame in one message carries that message's nonce.
  • TestPlannerInputEnvelopeSplit: the full planner round input has a frame for each derived field kind (worker_output, finding, hints, recent_facts, finding_list, cold_digests, related_tasks, agent_hints). Derived text appears only inside the frames. The goal, the constraints and the operator hint appear only outside, and the operator hint is labelled [operator] and sits in the operator block.
  • TestWorkerOverviewEnvelopeSplit: the same split holds for the worker launch overview, and coverage is still dropped.
  • TestFactSummaryCapped: compactFact caps at factSummaryMax.
  • TestRenderTriggersHintOrigin is updated for the new signature and framing.

Results in WSL Kali (go1.26.10 toolchain):

  • go build ./...: ok
  • go vet ./...: ok
  • go test -count=1 ./agent: ok github.com/Autumn-27/artex/agent. 91 pass, 36 skip, 0 fail. The skips are the PG-unit tests (no database configured locally); CI runs them.

Mutation check

Each mutation was applied, the tests were run, and the code was restored.

  1. In renderTriggers, env.wrap("finding", ev.Detail) was replaced with ev.Detail. TestPlannerInputEnvelopeSplit failed with "no frame for derived field finding" and reported the derived text outside the frames.
  2. In renderOverviewSections, the derived fields were written without env.wrap. TestPlannerInputEnvelopeSplit and TestWorkerOverviewEnvelopeSplit failed on every derived field.

After the restore, all tests pass.

Limits

  • The envelope is advisory to the model. It marks data as data. It does not stop a model from following an instruction carried by a derived field. How much it reduces plan hijacking has not been measured. That needs the phase-C lab run (instruction-shaped text in a benign target, before and after), so no claim beyond the framing is made here.
  • Fields not framed:
    • Intent summaries (open_intents, running_intents, recent_done_intents) are written by the planner and can relay derived text. They are outside the F3 list and stay plain.
    • The cancelled-intent trigger line, task_credentials and the stagnation warning also stay plain.
    • The graph_overview tool result and the other tool results are framed only by the Norma boundary (F9) when it is on.
  • Origin labelling: a hint with origin human is treated as the operator's. The main-agent chat writes that origin, so a main agent that transcribes target text into a hint would still be labelled operator (see F4 and F6).
  • Neutralisation is case-sensitive and fixed-string: a look-alike such as artex_data or a homoglyph is not rewritten. The per-message nonce is what such text cannot forge.

…, F3)

The planner and worker user messages now carry worker output, finding
details, recent_facts, finding_list, cold_digests, related_tasks and hints
not written by the operator inside a code-owned data envelope whose nonce
comes from crypto/rand (16 bytes, hex) once per message. Embedded marker
stems are neutralised with a fixed-string replacement. The task goal, goal
nodes, constraints and the operator's own hints stay outside, labelled
operator. Overview fact summaries are capped at factSummaryMax (160 runes),
the cap list_facts already uses.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant