Skip to content
55 changes: 54 additions & 1 deletion docs/user_guides/agents/evaluation/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,51 @@ template = evals.evaluators.save(

Applying a saved evaluator copies its checks into the suite, so a published suite keeps meaning what it meant even if the library entry changes later.

### How a judge decides pass or fail

An `llm_judge` asks its model for one score per criterion, on a scale of 1 to 5 unless `score_range` sets another.
The model does not return the verdict; the judge works it out from those scores in three steps:

1. **Weighted average.** Each score is multiplied by its criterion's weight, and the total is divided by the sum of the weights.
Weights are relative, so weights of 2 and 1 make the same judge as weights of 1 and 0.5.
A weight defaults to 1, and a weight of 0 means the criterion is scored and shown but does not count.
2. **Pass score.** The trial passes when the average reaches `pass_score`, on the same scale as the scores.
It defaults to three quarters of the way up the scale, which is 4 on a 1 to 5 scale.
3. **Floors.** A criterion can have a floor, shown as **never below** in the UI.
A score under the floor fails the trial however high the average is.

```python
check(
"llm_judge",
"gives sound advice",
criteria={
"correct": {"weight": 2, "description": "Is the advice correct?"},
"complete": {"weight": 1, "description": "Does it answer everything asked?"},
"disclaimer_present": {
"weight": 0.5,
"critical_min": 4,
"description": "Does the reply say it is not financial advice?",
},
},
thresholds={"pass_score": 3.5},
)
```

Use a floor, not a large weight, for anything that must never be traded away, such as safety or a required disclaimer.
A weight only changes how much a bad score pulls the average down, so enough good scores elsewhere still carry the trial.

!!! note "The model never sees weights or floors"
The judge's model is shown each criterion's name and description, and scores each one on its own.
Raising a weight does not make the model stricter about that criterion; it only makes the criterion count for more in the average.
To change how a criterion is scored, change its description.

If the model leaves out a criterion's score, or returns something that is not a number, the trial is ungradable rather than failed, because an average over whichever criteria came back would measure something different each time.
A score outside the scale is clamped to it before the average and the floors are checked.

The trial's score is the weighted average scaled to 0 to 1.
Its assertions record what the verdict was made from: the raw score per criterion, the weighted score, the pass score, any floors that were broken, and the model's reasoning for each criterion.
The reason reads, for example, `scored 3.80 of 5, needs 4` or `below the floor on disclaimer_present`, followed by that reasoning, for passes as well as failures.

## Running a suite

```python
Expand Down Expand Up @@ -155,7 +200,13 @@ So a passing gate does not always describe the newest execution: when the latest
A suite version that has never run against this deployment fails its check rather than being skipped, because untested is not the same as passing.

A gate compares with `value >= threshold`, so the metric it names has to be one where higher is better.
It also reads a recorded `0.0` as a number like any other: a run in which nothing was gradable records `pass_rate` of `0.0` and gates on that, and only a metric the run never recorded at all is reported as missing evidence.
A run in which nothing was gradable records no `pass_rate`, so a gate on it reports missing evidence rather than a pass rate of `0.0`.
Recorded as `0.0`, a run whose judge could not be called read as an agent that got every answer wrong.
The run's `ungradable_rate` says how much of it went ungraded, and `agent_error_rate` how much of it the agent did not answer.
A run of nothing but `AGENT_ERROR` trials still records `pass_rate` `0.0` and fails its gate, because nobody got an answer, and `agent_error_rate` of `1.0` beside it says the agent was down rather than wrong.

A trial whose turn takes longer than the evaluation job allows is recorded as `TIMEOUT`, which counts as a failure like an agent error, and `timeout_rate` says how many there were.
The limit is ten minutes by default; set `trial_timeout_s` on the evaluation job, up to an hour, for an agent whose turns take longer, such as one that runs analysts with web search.

## Red-team suites

Expand Down Expand Up @@ -194,6 +245,8 @@ for suite in history:
```

A trend is between runs that graded something: a run that died carries no verdicts, so comparing against it would read every failure as new and every standing failure as absent.
A task whose every trial was an agent error or a timeout is reported as `unanswered` rather than as a new failure or a regression.
A run the agent answered nothing in is never the baseline either, so the run after an outage is compared with the last run the agent answered in, rather than being credited with fixing every task.

Cases are matched across runs by the question they ask rather than by row id, so a new version of a suite that copies its tasks keeps its history instead of reporting every carried-over case as new.

Expand Down
4 changes: 4 additions & 0 deletions docs/user_guides/agents/failure_analysis/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ run = evals.runs.wait(evals.jobs.analyse(job), timeout_s=900)
```

Like a monitor, the analysis continues from where it last stopped, so each verdict is triaged once.

It also starts on its own when the deployment's evaluation job asks for it, which it does by default.
When an execution's last run finishes, the analysis runs if a suite found a new failure or a regression, or if a monitor failed a conversation it graded.
The same failures again start nothing, and since an analysis reads only the verdicts given after its previous run, an hourly monitor that keeps failing on the same kind of turn does not pay for the same triage twice.
`budget_calls` caps how many model calls one run may spend; what the budget does not reach waits for the next run rather than being skipped.

A judge is needed, and any OpenAI-compatible endpoint will do, including one you host yourself.
Expand Down
40 changes: 40 additions & 0 deletions docs/user_guides/agents/memory/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,46 @@ Deleting it brings the folded turns back and the next fold writes a fresh one.

Erasing a conversation leaves durable facts about the person alone, because starting a new chat must not erase what the agent knows about you.

### Reading memory from Python

The Python client reads the same memory without going through the agent, from the feature groups it is kept in, and searches earlier conversations by similarity:

```python
import hopsworks


agents = hopsworks.login().get_agent_serving()
agent = agents.get_agent("support_agent")

# the conversations the agent stored, most recently active first
for conversation in agent.memory_conversations(subject="dana@example.com"):
print(conversation["conversation_id"], conversation["message_count"])

# one of them as stored: messages, the rolling summary, and whose it is
stored = agent.memory_transcript(conversation["conversation_id"])
print(stored["subject"], stored["summary"])

# what the agent remembers about a person
for fact in agent.memory_facts("dana@example.com"):
print(fact["key"], fact["value"], fact["updated_at"])

# earlier messages nearest in meaning to a question
for hit in agent.memory_search("what do they eat?", subject="dana@example.com", k=5):
print(hit["score"], hit["role"], hit["content"])
```

None of them needs the agent running.
`memory_conversations`, `memory_transcript` and `memory_facts` read the project's memory feature groups, `agent_memory_messages`, `agent_memory_conversations` and `agent_memory_facts`, filtered to this deployment, and apply the rules the agent applies: a conversation is filed under the latest person it was bound to, and expired facts are left out.
So what an agent remembered can still be read after it is stopped.
They differ from `agent.conversation(session_id)`, which rebuilds what was said from the traces and exists whether or not the agent keeps memory.

With semantic search on, every message the agent ingests is also embedded into the deployment's own feature group, `agent_memory_<deployment id>`, and `memory_search` runs a similarity search over it with the same subject and conversation filters the agent's own `search` tool uses.

The query has to be embedded by the model that embedded the messages, otherwise the distances mean nothing.
That model is recorded in the feature group's description, and by default `memory_search` loads it as a sentence-transformers model, which needs `sentence-transformers` installed where you run it.
For any other model, pass `embedder=`, a callable from text to a vector; when it carries a `model_id` that differs from the recorded one, the search is refused rather than answered with unrelated neighbours.
An agent without semantic search has no memory feature group, and the search returns nothing; it never creates one.

### Where the access boundary is

The boundary is **the project's agents**.
Expand Down
Loading