| name | reference |
| description | Component-authoring reference for HarnessX evolve rounds. Covers the @tool signature and TOOL_SPEC workflow, MultiHookProcessor (hook dispatch table, event fields, messages-mutation contract), system-prompt template editing (Cases A/B/C), config.yaml shape, and the signal→knob guide for the Configuration lever. Read when implementing any lever — Action, Control, Instruction, or Configuration. |
Reference — HarnessX component authoring
Where things live
| File | Purpose |
|---|
output_dir/config.yaml | The new HarnessConfig the caller loads |
output_dir/tools/<name>.py | One Tool object per file |
output_dir/processors/<name>.py | One MultiHookProcessor subclass per file |
output_dir/prompt/<name> | System prompt for SystemPromptProcessor |
All authored files are referenced from config.yaml with absolute
paths. Relative paths are rejected by the loader.
Authoring a new @tool
This section assumes you've already decided Action is the right
lever. For whether to author a new tool at all (vs. retune an
existing one, write a Control hook, or edit the prompt), see the
analyze skill — lever choice lives there, authoring mechanics
live here.
Before you type — write the shape spec
For anything larger than a short stdlib helper, write the spec in
_meta_scratch/TOOL_SPEC.md before any code. Short sections, no
prose padding:
# TOOL_SPEC — <one-line capability name>
## Capability name
What the agent literally cannot do right now.
## Class, not single task
Which class of failing tasks would this help? If only one exotic
task — abandon, this isn't a capability gap.
## Inputs the agent will pass
Real examples from failing trajectories — URLs, file tokens,
queries, record ids. Synthetic examples are disallowed; they would
pass dry-fire but tell you nothing about how the tool behaves on the
inputs the benchmark keeps presenting.
## Output shape the caller needs (not what you want to produce)
From the caller's POV: structured string? count? page-indexed text?
When the agent gets this back, what can it do with it?
## Failure modes
Named modes — auth denied, timeout, unsupported format, empty
result, rate-limited. What does the tool return in each? Never
return `""` silently — always a diagnostic string the caller can
detect.
A spec you cannot write honestly is a spec that is too broad —
narrow it. The spec's value is framing the inputs concretely and
pinning anti-patterns (no generic replacements, no hardcoded task
ids) at the design stage.
Pre-flight
For pure-stdlib utilities, the pre-flight is small:
- Dump the
Tool dataclass fields — learn what the decorator
builds before you write a call site for it:
python -c "from dataclasses import fields; from harnessx.tools.base import Tool; \
[print(f.name, '=', f.type) for f in fields(Tool)]"
- Read the nearest builtin in
harnessx/tools/builtin/ that is
structurally similar (same return shape, same env access pattern,
same failure mode). Match its signature and error-handling style;
don't invent a new convention.
If your tool touches the network or imports a third-party package:
- Write the imports as usual. If dry-fire reports
ImportError, either fall back to stdlib or record the dep in
_meta_scratch/NEEDS_FROM_HUMAN.md and try a different approach.
- Check prior art under
harnessx/tools/builtin/ for the
keyword in your diagnosis — but decide by observed behaviour on
this benchmark's inputs, not by whether a similarly named builtin
exists (see Prior-art rule).
Minimal shape
from harnessx.tools.base import tool
@tool(description="One-line capability sentence — what can the agent now do that it couldn't before.")
async def <fn_name>(<typed args>) -> str:
...
Concrete tool examples (search fallbacks, file-format parsers,
domain-specific clients) belong in the recipe's <bench>-playbook
skill — they encode the benchmark's input distribution and failure
classes, which this skill deliberately does not.
Rules:
- Exactly one module-level
Tool object per file (produced by
the @tool decorator on a function).
async def or regular def — both are supported.
- Input args must have type hints; they become the JSON Schema.
- Return a string, or something stringifiable. Tool outputs feed
back to the model as text.
- Third-party imports: just import at module top-level. Dry-fire
- replay-gate handle env uncertainty downstream.
- No hardcoded task ids, URLs, or answers in fn bodies — the
literals validator rejects these (R4).
Reference from config.yaml:
tools:
builtin: [Read, Write, Edit, Glob, Grep, Bash, WebSearch, WebFetch]
custom:
- file:///abs/path/output_dir/tools/<name>.py::<fn_name>
The string after :: is the function name (what the tool will be
called as). If the inner agent should use your tool instead of
a builtin, remove the builtin's PascalCase name from
tools.builtin.
Authoring a new MultiHookProcessor
Mandatory pre-flight
Most contract violations in evolve rounds come from skipping one of
these. Walk them in order every time, regardless of how trivial the
hook looks:
- Pick the hook deliberately. Cross-check against the
hook dispatch table — an
on_* method
whose suffix isn't a known event becomes an import-time
TypeError.
- Dump the event dataclass (see
Event fields)
before typing any
event.<field> access. Field names drift;
don't trust memory or pattern-match from another processor.
- If your hook mutates
event.messages (only on_step_start
and on_before_model are allowed to), read
harnessx/core/processor.py::_validate_messages_contract
end-to-end, plus check_post_hook_invariants in the same
file. Then re-check your hook body against every violation
category listed in Messages-mutation contract.
- Grep prior art under
harnessx/processors/control/,
context/, memory/. If a builtin's observed behaviour is
close to what you want, a config-layer retune beats a new file.
Minimal shape
from __future__ import annotations
import dataclasses
from harnessx.core.events import ToolResultEvent, Message
from harnessx.core.processor import MultiHookProcessor
class ToolErrorTailInjector(MultiHookProcessor):
"""When a tool errors, append a short tail note to its result so
the agent sees the failure mode without re-reading the full
stderr block."""
_singleton_group = "tool_error_tail_injector"
_order = 25
def __init__(self, tail_chars: int = 200):
self.tail_chars = int(tail_chars)
async def on_after_tool(self, event: ToolResultEvent):
result = event.result or ""
if not isinstance(result, str) or not event.is_error:
yield event
return
tail = result[-self.tail_chars:].strip()
annotated = f"{result}\n\n[tail] {tail}"
yield dataclasses.replace(event, result=annotated)
Zero-arg-friendly constructor: the loader may instantiate with no
kwargs if the YAML entry doesn't pass any.
(Benchmark-specific processors — for example, one that enforces a
particular answer-format marker — belong in the recipe, authored by
the meta-agent when the benchmark's playbook identifies a grader
requirement that the current config isn't meeting.)
Hook dispatch table
| Event class | Method name |
|---|
TaskStartEvent | on_task_start |
StepStartEvent | on_step_start |
BeforeModelEvent | on_before_model |
ModelResponseEvent | on_after_model |
ToolCallEvent | on_before_tool |
ToolResultEvent | on_after_tool |
StepEndEvent | on_step_end |
TaskEndEvent | on_task_end |
Name a method on_* but not one of those → import-time TypeError.
Use @on(CustomEventClass) to handle a custom event under a free
name.
Event fields — look them up, never guess
python -c "
from dataclasses import fields
from harnessx.core.events import BeforeModelEvent # swap in your event
for f in fields(BeforeModelEvent):
print(f.name, '=', f.type)
"
Referencing a non-existent field (event.message when the field is
messages) passes canonicalize but explodes on the next benchmark
run. Copy-paste field names from the dataclass.
Messages-mutation contract
Only two hooks may touch event.messages: on_step_start and
on_before_model. Violations surface at runtime as CONTRACT [<type>] hook=... processor=... warnings, and raise
ContractViolationError under HARNESSX_CONTRACT_MODE=strict.
Single source of truth is
harnessx/core/processor.py::_validate_messages_contract (plus
check_post_hook_invariants for chain-level rules). The list below
is a scanning checklist.
Violation categories:
- Empty messages (
empty_messages) — before_model output must
be non-empty.
- Length delta out of range (
length_exceeded) — before_model:
a processor may not remove messages, and may add at most +1 per
step.
- Tail role & insertion rules — when
before_model adds +1:
the pre-hook tail must not already be user (content-edit only
in that case), and the inserted message itself must have
role="user".
- Chain-level insertion cap — across all processors in a
single
before_model chain, at most +1 user insertion total.
system_window invariance — step_start may not change
messages[0] when its role is "system".
- Structural-vs-content edits —
step_start may not ONLY change
the tail (that's before_model's job). before_model with
delta==0: if tail is user, only its content may change and the
prefix must be identical; if tail is not user, no change allowed.
Chain-level invariants in check_post_hook_invariants:
system count ≤ 1 and must sit at index 0.
raw_track and effective_track must have equal length, matching
roles at every index, and matching tool_call_id for
role=='tool'.
Safest default when unsure: a pure content rewrite of the existing
tail user message (delta==0, prefix untouched) — valid in both
step_start and before_model's user-tail branch.
Yielding
A hook is an async def generator. Yield one or more events:
yield event — pass through unchanged
yield dataclasses.replace(event, messages=new_tuple) — mutate a
frozen dataclass field
yield ProcessorTriggerEvent(...) — emit an observability event
alongside the main one
Yielding nothing blocks the hook; don't do this unless you
intentionally want to suppress a tool call or model turn.
Ordering
_singleton_group prevents two processors of the same kind
stacking. _order: 10 = pre, 20 = normal, 30 = post. Builtins use
token_budget=10, cost_guard=10, checkpoint=10,
loop_detection=20, otel=30. Slot new guards at 15–25 unless you
specifically need to run before or after a named one.
Reference from config.yaml:
processors:
- _target_: file:///abs/path/output_dir/processors/tool_error_tail_injector.py::ToolErrorTailInjector
tail_chars: 200
Kwargs in the YAML map directly to the processor's __init__ args.
Editing the inner agent's system prompt (instruction layer)
The inner agent's system prompt is produced by a SystemPromptProcessor
entry in config.yaml. How you edit it depends on which builder
the processor currently uses — first read current_config and
look at the system_builder._target_ of the SystemPromptProcessor
entry before you plan a change. The three shapes you'll see in practice:
Case A — the builder is TemplateSystemPromptBuilder
- _target_: harnessx.processors.context.system_prompt.SystemPromptProcessor
system_builder:
_target_: harnessx.processors.context.strategies.system_prompt.template.TemplateSystemPromptBuilder
template_path: /abs/path/to/baseline.j2
A .j2 file exists and is the source of truth for the prompt. Do
not edit it in place. Instead:
Read the current template at template_path.
Write a modified copy to output_dir/templates/<name>.j2.
- In your new
config.yaml, update the SystemPromptProcessor
entry's template_path to the absolute path of your copy.
Case B — the builder is a static Python class (_StaticSystemPromptBuilder, DefaultSystemPromptBuilder, or a benchmark-provided subclass that returns a hardcoded string)
- _target_: harnessx.processors.context.system_prompt.SystemPromptProcessor
system_builder:
_target_: <some_package>.<SomeStaticBuilder>
There is no template_path — the prompt is baked into a Python class.
Two legal paths:
-
B1 (preferred — promote to a template). Read the builder's
source to extract the literal prompt string it returns. Write
that string (with your edits) to output_dir/templates/<name>.j2.
Then swap the builder in config.yaml:
system_builder:
_target_: harnessx.processors.context.strategies.system_prompt.template.TemplateSystemPromptBuilder
template_path: /abs/output_dir/templates/<name>.j2
extra_context: {}
This is the smallest diff that gives you edit power. The baseline
class no longer runs — your template is now authoritative.
-
B2 (overlay). Author a MultiHookProcessor with an
on_before_model hook that inserts a supplementary user message
before the model call. The original static builder keeps running; your
processor adds to its output. Right choice when the static prompt is
load-bearing (it encodes a protocol the harness depends on) and you
only want to append a nudge, not replace. See the MultiHookProcessor
section above for the shape.
Case C — the builder is NullSystemPromptBuilder
- _target_: harnessx.processors.context.system_prompt.SystemPromptProcessor
system_builder:
_target_: harnessx.processors.context.strategies.system_prompt.null.NullSystemPromptBuilder
The benchmark intentionally owns the prompt through a different
mechanism (e.g. role-play turns injected per step). Editing the system
prompt usually isn't the right lever — the prompt is empty on
purpose, and the benchmark feeds its own instructions in-turn. Prefer
Control (a processor that edits the messages the benchmark injects) or
Instruction-via-a-new-skill-file over trying to stuff content into the
system slot.
Rules for all instruction-layer edits
- Check the benchmark's grader before touching answer-format
rules. Graders vary across benchmarks — some parse a marker
string, some run a judge LLM, some run verifier scripts inside a
sandbox, some use a simulated user. Removing format requirements
that feed the grader silently breaks the round. Consult the
<bench>-playbook skill for what the grader actually reads
before editing any answer-format block.
- Keep core tool descriptions that the inner agent needs to know
what's available. Which tool descriptions are load-bearing varies
by benchmark — check the playbook.
- Make edits surgical. Add or modify specific sections; don't
rewrite from scratch. A small targeted change is easier to attribute
when it lands or reverts.
- Bundle by evidence, not by reflex. Multiple instruction changes
in the same round are fine when each is independently grounded —
its own cited candidate, its own cluster of affected tasks, and
Pareto-positive against regression risk + cost shift. See
analyze
skill → Multi-lever rounds for the general rule. What to avoid
is the opposite reflex: stacking two weakly-supported edits to
"cover more ground", which makes attribution impossible when the
round reverts. Rule of thumb: if you can write a separate
## Candidate C-N section (with its own Signal / Verified /
Retroactive check / Why X not Y) for each edit, they can ship
together; if two edits collapse into one candidate with the same
evidence, pick the stronger framing and keep the other as a
follow-up in the journal.
config.yaml shape — non-negotiable
processors:
- _target_: harnessx.processors.context.system_prompt.SystemPromptProcessor
system_builder:
_target_: harnessx.processors.context.strategies.system_prompt.template.TemplateSystemPromptBuilder
template_path: /abs/path/to/template.j2
extra_context: {}
- _target_: harnessx.processors.control.token_budget.TokenBudgetProcessor
ratio: 0.85
- _target_: harnessx.processors.control.cost_guard.CostGuardProcessor
max_usd: 2.0
- _target_: file:///abs/path/output_dir/processors/tool_error_tail_injector.py::ToolErrorTailInjector
tail_chars: 200
tools:
builtin: [WebSearch, WebFetch, Browser, Read, Write, Edit, Glob, Grep, Bash]
custom:
- file:///abs/path/output_dir/tools/<name>.py::<fn_name>
Key rules:
processors: is a flat list, not a dict keyed by hook bucket.
tools.custom entries are bare strings (file://...::fn_name),
not _target_ dicts.
- All
file:// paths are absolute; relative paths are rejected.
- Kwargs in each entry map directly to the target's
__init__ args.
Starting point for edits
Copy the current config, then edit the copy — smaller diff = fewer
ways to break canonicalize:
cp <current_config_path> <output_dir>/config.yaml
No-op case
When no change is warranted, a valid no-op config.yaml is a byte-
for-byte copy of <current_config_path>. Still write it; the caller
treats absence of config.yaml as a failure.
Common canonicalize failures
| Error | Likely cause |
|---|
'processors:' must be a flat list | Legacy hook-bucket form. Remove the '*': wrapper; emit a flat list of _target_ dicts. |
'tool_registry:' is not a recognised key | Legacy format. Put tools under tools.builtin / tools.custom. |
tools.custom entries must be '...' strings | You wrote {_target_: file://...} instead of the bare string. Drop the _target_: wrapper. |
TemplateSyntaxError | Fix the Jinja in the .j2 file named in the error. |
FileNotFoundError on template_path | You referenced a template you forgot to write. |
ModuleNotFoundError from file:// import | Authored file path typo, or ::symbol doesn't match the exported name. |
singleton_group ... conflicting registrations | Same processor _singleton_group listed twice. |
Verify before end_turn:
python -c "from harnessx.core.harness import HarnessConfig; HarnessConfig.from_yaml_file('<output_dir>/config.yaml').canonicalize(); print('OK')"
Or use the validate skill's CLI suite.
Tuning existing builtins (Configuration layer)
When the lever is Configuration rather than Action/Control/Instruction,
edit kwargs on an existing processor in place. The common tunables:
- _target_: harnessx.processors.control.cost_guard.CostGuardProcessor
max_usd: 2.0
- _target_: harnessx.processors.control.loop_detection.LoopDetectionProcessor
window_size: 10
threshold: 4
- _target_: harnessx.processors.context.token_budget.TokenBudgetProcessor
ratio: 0.85
- _target_: harnessx.processors.control.checkpoint.CheckpointProcessor
every_n: 5
Judgement on when to move each knob is trajectory-driven. Use the
signal table below as a starting point, read the trajectory evidence,
pick the direction, retune, verify with canonicalize. The
orchestrator's gating layer will revert a miscalibration.
When to move which knob
| Signal in trajectories | Knob to move | Direction |
|---|
Recurring exit_reason: budget_exceeded while agent was still making progress | CostGuardProcessor.max_usd | raise |
| Most failures hitting the step limit (steps = max_steps) | Not a config knob — record as NEEDS_FROM_HUMAN: raise max_steps in runner | — |
exit_reason: loop_detected on tasks where the agent was visibly making progress | LoopDetectionProcessor.threshold | raise |
| Agent repeats the same action many times before loop detection fires | LoopDetectionProcessor.threshold or window_size | lower |
| Compaction artifacts visible (lost context mid-task, agent re-asking already-answered questions) | TokenBudgetProcessor.ratio | lower |
The signal column describes a pattern across multiple tasks, not a single incident. A one-off is
idiosyncratic — log it as NEEDS_FROM_HUMAN rather than tuning a knob.
Prior-art grep
Before authoring anything new, check what's already there:
harnessx/processors/control/ for Control-lever components
(token_budget.py, cost_guard.py, loop_detection.py,
parse_retry.py, repeated_edit_detector.py,
self_verification.py, tool_failure_guard.py, …)
harnessx/tools/builtin/ for Action-lever components
(local file reads, web search, HTTP fetch, shell commands).
The goal of this grep is mechanical orientation — match naming
conventions, signature style, error-handling idioms of the nearest
sibling. The decision of whether a similarly named builtin
covers your need lives in analyze skill → Disambiguating
adjacent levers, and turns on observed behaviour on this
benchmark's trajectories, not on filename similarity.
Integration check — orchestrator post-flight
After your end_turn, the orchestrator runs:
- Validity (always):
canonicalize + dry_fire + contract
synthetic_task replay. Each in turn; first failure stops
and fails the round.
- Policy (when config diff is non-empty):
novelty (no
re-proposing reverted hypotheses) + evidence (candidates.md
- cited_candidates cross-ref).
- Advisory (never blocks):
literals writes to
_meta_scratch/LITERALS_WARNING.md when hardcoded task-shaped
literals appear in authored code. Fix them when you see the
warning — they're not fatal but they signal a candidate that
likely won't generalize.
Run the validity checks yourself before end_turn via the
validate skill — that catches the common bugs without paying
the orchestrator-replay round cost.
Anti-patterns — apply to tools, processors, and templates alike
- Generic replacement. "A better WebFetch that handles
everything." Scope to the cluster. Existing tools compete in the
general case; new tools compete on a specific class of inputs.
- Lifting an implementation from outside this repo. Other
teams' blog posts / papers describe shapes that worked on their
input distribution. You have not seen their failing trajectories.
Use outside work as shape inspiration, not as code.
- Skipping the shape spec (TOOL_SPEC.md). Typing
@tool
without the spec produces components that don't match the
caller's actual need.
- Multi-responsibility component. "fetch-and-extract-and-
summarize" → split into separate
@tools or processor hooks.
One responsibility per file. The agent composes; you don't.
- Sprawling third-party deps.
pdfplumber + pandas +
openpyxl + sympy in one tool. Pick one axis per tool.
- Silently suppressing failure with
"" / None. Return a
diagnostic string ([<tool> failed: <reason>]), not empty.
Empty gets absorbed; diagnostics let the agent fall through.
- Hardcoded task ids, URLs, or answers in fn bodies — R4 rule.
Triggers a
literals advisory warning; not fatal on its own,
but such components almost never survive the next benchmark
round (they only work on the one task the author memorised).
- For processors: mutating
event.messages outside
on_step_start / on_before_model — contract violation,
automatically rejected.
- For templates: editing the target's source
.j2 in place —
always copy to output_dir/templates/<name>.j2 and point
template_path at the copy.
End-of-turn checklist