Skip to content
tnsaiDocs
GitHub

Audit agent actions

Every agent acts as an AgentPrincipal, inside an AuthorityScope. With a LiabilitySink, each action it runs also leaves an AgentLiabilityRecord: which agent ran which action, under which scope, and how it ended. A reputation score and agent-to-agent payments build on the same records.

The builder requires the principal and the scope; see Principal and authority. This page covers the identity, the records and what you can do with them.

Issue an identity

LocalIdentityProvider issues principals from an AgentDescriptor and signs them with an in-process HMAC-SHA-256 key.

static AgentPrincipal issue(LocalIdentityProvider identities) {
    AgentDescriptor descriptor = AgentDescriptor.builder()
        .agentClass("com.example.ResearchAgent")
        .systemPrompt("You research supplier contracts.")
        .toolNames(List.of("web_search", "read_file"))
        .model("gpt-4o")
        .buildHash(System.getenv("GIT_SHA"))           // optional; feeds the fingerprint
        .build();
    return identities.issue(descriptor);
}

static boolean checkLater(LocalIdentityProvider identities, AgentPrincipal principal) {
    byte[] signature = identities.sign(principal);
    return identities.verify(principal.id(), signature).isPresent();   // same provider instance only
}
Principal fieldLocalIdentityProvider value
id()A random UUID
version()A hash of the agent class, system prompt, tool names and model. It changes when the agent's behaviour changes
runtimeFingerprint()A hash of the same fields plus buildHash and metadata
attestation()Empty
  • issue returns the same principal for the same class, prompt, tools and model, even when buildHash or metadata differ. The first call's fingerprint stays.
  • verify only recognises principals that the same provider instance issued. It compares signatures in constant time.
  • new LocalIdentityProvider() generates a random 32-byte key, so signatures do not survive a restart. Pass your own key, at least 16 bytes, to new LocalIdentityProvider(key) to keep them valid.

For an identity that another process or organisation can check, implement AgentPrincipalProvider (issue and verify) over your own trust root.

Record every action

Pass a sink to the builder. Without one, no records are built.

static Agent auditedAgent(LLMClient llm, Role role, AgentPrincipal principal, LiabilitySink sink) {
    return AgentBuilder.create()
        .llm(llm)
        .role(role)
        .principal(principal)
        .authorityScope(AuthorityScope.unrestricted(Duration.ofHours(8)))
        .liabilitySink(sink)                            // optional; one record per action
        .build();
}

After each action, the executor writes one record:

FieldValue
agentId, agentVersionThe principal's id() and version()
invokedAtWhen the action finished
actionAction type, name and the arguments as passed
scopeThe agent's authority scope
liabilityLOW for LOCAL actions, MEDIUM for LLM, MCP_TOOL and WEB_SERVICE
correlationIdA new random id for each record
outcomehash: and the hex hash code of the result, ERROR: and the exception, or empty for a null result
signatureEmpty
  • A sink that throws is logged, and the action's result is kept.
  • The arguments are stored unredacted. If they can carry secrets or personal data, redact them in your sink; see Redact sensitive data.
  • The other tiers, NONE, HIGH and REGULATORY, are never assigned by the executor. A sink that wraps yours can reclassify records before storing them.

Store the records

LiabilitySink has two methods: record(rec) and query(AuditQuery). The only shipped implementation is RecordingLiabilitySink, which keeps records in memory. It also has records(), recordCount(), lastRecord() and clear(), which suit tests. For records that last, implement LiabilitySink over your own storage, and make it append-only.

Query the records

static void review(LiabilitySink sink, String agentId) {
    AuditQuery lastDay = AuditQuery.builder()
        .agentId(agentId)
        .liabilityAtLeast(LiabilityClass.MEDIUM)
        .from(Instant.now().minus(Duration.ofDays(1)))
        .limit(100)
        .build();
    try (Stream<AgentLiabilityRecord> records = sink.query(lastDay)) {
        records.forEach(rec -> System.out.println(rec.invokedAt() + " " + rec.action().name()
            + " " + rec.liability() + " " + rec.outcome().orElse("no result")));
    }
}

Every filter is optional; AuditQuery.all() matches everything. liabilityAtLeast includes that tier and the ones above it. from is inclusive and to is exclusive. A limit of 0, the default, means no limit.

Score reputation

InMemoryReputationLedger computes a score between 0 and 1 from the records in a sink.

static boolean trusted(LiabilitySink sink, String agentId) {
    ReputationLedger ledger = new InMemoryReputationLedger(sink);
    ReputationScore score = ledger.scoreOf(agentId);
    return score.recordCount() >= 20 && score.score() >= 0.95;   // an agent with no records scores 1.0
}
  • A record is a miss when its outcome starts with ERROR, or when its scope does not permit its action type or target. Every other record is a pass.
  • Each record is weighted by its tier: NONE and LOW count 1, MEDIUM 3, HIGH 9 and REGULATORY 27.
  • The score is 1 minus the weighted misses divided by the weighted total. It reads the whole history on every call.
  • historyOf(agentId, window) returns the records from the last window.

Pay other agents

PaymentBroker prices work with quote and settles it with settle. Every agent has one; agent.getPaymentBroker() returns PaymentBroker.noOp() unless you pass another to AgentBuilder.paymentBroker(...) or agent.setPaymentBroker(...).

static String pay(Agent agent, AgentPrincipal payer, AgentPrincipal payee) {
    PaymentBroker broker = agent.getPaymentBroker();   // PaymentBroker.noOp() unless you set one
    Service lookup = new Service("contract-lookup", "request", BigDecimal.ONE, Optional.empty(), Map.of());

    Quote quote = broker.quote(payer, payee, lookup);
    return switch (broker.settle(quote)) {
        case Settlement.Settled s -> "paid " + quote.priceUSD() + " USD, transaction " + s.transactionId();
        case Settlement.AlreadySettled a -> "already paid, transaction " + a.transactionId();
        case Settlement.Rejected r -> "refused: " + r.reason();
        case Settlement.Expired e -> "quote expired";
    };
}
SettlementMeaning
SettledPaid, with a transaction id and an optional receipt URL
AlreadySettledA quote with the same idempotency key was already paid; carries the first transaction id
RejectedThe broker refused, with a reason
ExpiredThe quote passed its expiresAt

The no-op broker quotes 0 USD, keeps quotes for one hour and settles at once, with a transaction id that starts with noop-. A broker can also refuse to quote by throwing PaymentBroker.QuoteRejectedException. For real payments, see Payments and the x402 broker.