How
You do not have to rewrite your agents. Put six components between the caller and the model. Move one task into each component. All six components are usual code.
This page uses ASD-STE100 Simplified Technical English. The Python library supplies all six components.
Six layers, one frame
Each layer reads one section of the frame only. A layer does not know about the other layers. Only the prompt builder calls a model.
| Layer | Consumes | Can refuse? |
|---|---|---|
| Admission control | constraints, capability, criteria presence | Yes — before any spend |
| Artifact resolution | context.artifacts | Yes — unresolvable reference |
| Dispatch | intent_type | No |
| Prompt construction | goal, inputs, reasoning, output sections | No |
| Validation | expected_output.acceptance_criteria | Yes — once retries are exhausted |
| Orchestration | quality, failure_policy, handoff | Yes — on budget exhaustion |
flowchart TD
S([Request arrives]) --> A["1 · Check limits"]
A -- breaks a rule --> X([Refused, nothing spent])
A -- allowed --> B["2 · Look up documents"]
B --> C["3 · Choose settings<br/>from the kind of request"]
C --> D["4 · Build the prompt"]
D --> E["5 · Call the model"]
E --> F["6 · Check the answer"]
F -- passes --> G([Result])
F -- "fails, retries left" --> D
F -- out of retries --> Y([Refused: standard not met])
class A enforce
class B context
class C route
class D,E model
class F check
class G ok
class X,Y refusal
The sequence is important. The layers that are cheap and certain operate first. The layers that are costly and uncertain operate after them.
Refuse before you spend
This layer is the most important one. It is also the most simple. It is a function that compares values with the policy. Your policy holds the maximum values. The frame holds the values that the sender asks for. If a value is more than the maximum, refuse the frame.
Do not decrease the value without a message. The caller must learn that its terms are not acceptable.
def admit(frame, policy):
violations = []
if frame.constraints.llm.maximum_tokens > policy.max_tokens_ceiling:
violations.append(Violation("TOKEN_CEILING_EXCEEDED",
"constraints.llm.maximum_tokens", ...))
escalation = set(frame.constraints.tooling.allowed) & SIDE_EFFECT_TOOLS
if escalation and frame.capability not in policy.privileged_capabilities:
violations.append(Violation("TOOL_ESCALATION",
"constraints.tooling.allowed", ...))
if not frame.expected_output.acceptance_criteria:
violations.append(Violation("UNENFORCEABLE_CONTRACT",
"expected_output.acceptance_criteria", ...))
return violations
If a frame does not pass admission, send a refusal frame. The refusal frame has refused_at="admission", a list of violations, and usage.attempts == 0. The caller learns which terms you refused. You did not spend money to find this.
Send a frame that asks for a tool that its capability cannot use. Then make sure that your code did not call the model. If the model operated, your constraints are only a request. This one test shows if your code conforms.
Derive the runtime from the intent
Give each intent type a profile. A profile has a model role, a temperature, a tool mode, and a validation mode. The profile controls the settings. The values in the frame are maximum values. A frame can ask for a lower temperature than the profile.
It cannot ask for a higher temperature. A frame can ask for tools. You do not have to give those tools to it.
PROFILES = {
"request": Profile(model="drafting", temperature=0.2,
tool_mode="read_only", pipeline="standard"),
"verify": Profile(model="checker", temperature=0.0,
tool_mode="none", pipeline="strict"),
}
flowchart TD
F["Request arrives"] --> D{"Which kind?"}
D -- request --> A["Do the work<br/>drafting model · temperature 0.2<br/>read-only tools · normal checks"]
D -- verify --> B["Check the work<br/>checker model · temperature 0.0<br/>no tools at all · strict checks"]
D -- critique --> C["Improve the work<br/>checker model · temperature 0.3<br/>read-only tools"]
D -- inform --> E["Tell someone<br/>may use write tools,<br/>but only if the job is trusted"]
class D route
class A model
class B,C check
class E orchestrate
Give a role name to each model. Do not use the name of the supplier. Your deployment selects the model for the checker role. Then you can change the model for one intent type. You do not have to change a frame or a prompt.
Read from an allowlist
Use one function to make the prompt text from the frame. Give that function a list of the permitted fields. The other fields are for the policy, for the routes, and for the log. Do not put them into the prompt.
Two rules carry most of the weight.
Send the limits as API parameters. Do not write them as sentences. Give the maximum token count to the client. Do not also put it into the prompt. That does not make the limit stronger. It only uses more tokens.
Keep some criteria away from the model. This rule is not obvious. If the source document contains email addresses, do not rely on the prompt. The words "do not include email addresses" do not stop the model. Only a scan of the result stops the model. The check in the prompt gives you nothing.
The same rule applies to a confidence limit. If you tell a model to be 80% sure, the model writes with more confidence. It does not give you more evidence.
Let the validator generate the retry
Apply each criterion to the result. Collect each failure as a repair note. Put those notes into the next attempt. Do not write a new prompt by hand. Stop the loop at failure_policy.retry_attempts.
report = validate(frame.expected_output, output, assessment)
if report.passed:
return result_frame(...)
if attempt <= frame.failure_policy.retry_attempts:
return retry(repair_notes=report.repair_notes)
return refusal_frame(refused_at="validation", violations=report.failures)
Write each repair note as an instruction. Do not write it as a report:
✗ validation failed: c2
✓ missing required section: Recommended Action. Add it.
The note is all the new data in the next attempt. If the note is not specific, the second attempt is not better than the first. Give care to these notes.
Emit typed events
Each layer writes a record. The record has a trace_id, an intent_type, a capability, and its own fields. The intent is a field. So you can group the records to get these numbers:
- The retry rate for each intent type. Does a check fail more than a first draft?
- The refusal rate for each policy rule. Which limit has the wrong value?
- The cost for each capability. Which capability uses the most money?
- The failure rate for each acceptance criterion.
The last number is the unusual one. If a criterion fails 80% of the time, the model is not usually the cause. The requirement is too difficult. Before you had criteria in fields, you could not see this.
Adopting incrementally
Instrument one boundary
Find the handoff between two agents that causes the most incidents. Use a frame at that handoff only. A frame is useful at one boundary. You do not need frames at all the boundaries.
Write the criteria first
Get one of your prompts. Write its acceptance criteria as checks. Usually you find that nobody made a decision about the result. That is the true defect. It is there if you use frames or if you do not use frames.
Add admission control in report-only mode
Send your usual traffic through the policy layer. Do not refuse frames yet. You will find frames that passed by chance. Start to refuse frames when the list has no more surprises.
Keep your output format
You do not have to make the model give JSON. The result can stay a document. But your code applies the criteria to that document before it goes to the caller.
Or install it
The Python library supplies all six layers. It also has adapters for LangGraph and for CrewAI. The example operates offline with a scripted executor. So you can do the refusal path and the repair loop again and again.
pip install "agentintentframe[langgraph]"
python examples/quickstart.py # offline, no API key