Facts, affordances, and pertinence in agent design
A concrete boundary for deciding which agent behavior belongs in deterministic code and which decisions should remain with the model.
An agent can fail in two opposite ways. It can invent state that the platform never supplied, or it can surface valid actions at an awkward moment. Both failures come from assigning a decision to the wrong component.
The useful split is facts, affordances, and pertinence. Facts describe what is true. Affordances describe what the platform currently permits. Pertinence decides which permitted action belongs in this conversation turn.
Compute facts from authoritative sources
This section defines the state the model may cite. A fact should carry enough provenance for the application to refresh it and for an evaluator to verify it.
The three layers become easier to reason about when represented separately:
from dataclasses import dataclass
from datetime import datetime
from typing import Generic, TypeVar
T = TypeVar("T")
@dataclass(frozen=True)
class Fact(Generic[T]):
value: T
source: str
observed_at: datetime
@dataclass(frozen=True)
class WorkspaceFacts:
workspace_id: Fact[str]
billing_state: Fact[str]
integration_connected: Fact[bool]
The model should receive the values and, when useful, their freshness. It should never calculate billing state from prose or infer whether an integration exists from conversation tone. Those are lookups against a source of truth.
Provenance also prevents a common testing mistake: a fixture can state a value without pretending it came from production. A synthetic source="fixture" makes the boundary explicit.
Derive affordances as an unordered set
This section turns facts into actions the platform recognizes. Eligibility belongs in code because it can affect authorization, billing, or persistent state.
An affordance derivation can stay deterministic and compact:
from dataclasses import dataclass
from enum import StrEnum
class Action(StrEnum):
CONNECT_INTEGRATION = "connect_integration"
UPDATE_PAYMENT = "update_payment"
OPEN_DASHBOARD = "open_dashboard"
@dataclass(frozen=True)
class Affordance:
action: Action
reason: str
def available_actions(facts: WorkspaceFacts) -> frozenset[Affordance]:
actions = {
Affordance(Action.OPEN_DASHBOARD, "workspace is available"),
}
if not facts.integration_connected.value:
actions.add(
Affordance(
Action.CONNECT_INTEGRATION,
"no integration is currently connected",
)
)
if facts.billing_state.value == "payment_required":
actions.add(
Affordance(
Action.UPDATE_PAYMENT,
"billing service requires a payment update",
)
)
return frozenset(actions)
Returning a set matters. A field named next_actions or a numbered list implies that application code has already chosen an order. If ordering is a product rule, encode and test it. If ordering depends on the conversation, do not smuggle a priority decision into the data structure.
The reason string is for auditability, not model improvisation. It explains which deterministic condition made an action eligible. The execution endpoint must check eligibility again because state can change after the prompt is assembled.
Let the model decide pertinence within the boundary
This section gives the model conversational discretion without giving it authority to create actions. The model may select zero or more action identifiers from the supplied set.
A structured output contract should keep the conversational message and selected action identifiers separate. Validation rejects every suggested action that is not present in the supplied affordance set.
Construct available from the affordance objects before validation. The prompt can tell the model to suggest an action only when it advances the user’s current goal. A user asking for an incident summary should not receive a billing reminder merely because that reminder is eligible.
Pertinence is therefore bounded judgment. The model decides whether and how to mention an action; code decides whether that action exists and whether execution is allowed.
Classify decisions by the cost of error
This section supplies a review method for ambiguous logic. Ask what a wrong decision can do.
If an error can expose data, mutate state, spend money, violate policy, or state a false account fact, the decision belongs in deterministic code. If an error produces only awkward timing, unnecessary repetition, or an unhelpful phrasing choice, the model can own it within a validated output contract.
Some decisions contain both parts. “The user can export this report” is an affordance computed from permissions; “mention export now” is pertinence. Split the function instead of assigning the combined decision to one side.
Test each layer independently
This section verifies that eligibility rules and conversational selection do not mask each other. Deterministic tests should cover every affordance condition and prove that a response cannot introduce an unavailable action.
Model evaluations can then focus on pertinence: whether a valid action was relevant, whether it interrupted a higher-priority request, and whether the message overstated what would happen. Those checks may be probabilistic; the authorization boundary is not.
Facts, affordances, and pertinence form a narrow contract between code and the model. Code establishes truth and permission, the model applies conversational judgment, and validation prevents that judgment from expanding the platform’s capabilities.