Skip to navigation

Manage Tasks & Tasksets

View as Markdown

A task is a stored, reusable definition of an agent-eval unit of work: an intent (what the agent should do), the inputs it receives, and the metrics that score it. A taskset is a named grouping of tasks. Both are first-class entities in the Evals plugin, addressed by workspace/name and managed through the nemo_helix SDK.

Use stored tasks and tasksets when you want to define an evaluation unit once and reference it across runs, share it across a team, or assemble suites — rather than re-declaring the intent, inputs, and metrics inline every time.

Concepts

ConceptWhat it isMembers
TaskA reusable agent-eval unit: intent, inputs, and the metrics that score it.References the metrics that score it.
TasksetA flexible grouping of tasks with a description and metadata.References member tasks by workspace/name, each pinned to an exact revision. Membership is a set — order is not significant and duplicate references are rejected.
RevisionAn immutable published snapshot of a task’s or taskset’s content, addressed by a content digest.Belongs to the task or taskset it snapshots.

Both are addressed by workspace/name. Names are unique within a workspace, limited to 255 characters, and must match ^[\w\-\.]+$.

Every stored task and taskset is versioned. Creating one publishes revision 1; replacing its content publishes the next revision. Earlier revisions stay readable for as long as the task or taskset exists — deleting it removes its revisions with it — which is what lets an evaluation be re-run against exactly the content it ran against the first time.

Publishing is idempotent. Replacing a task with content identical to its current revision publishes nothing and returns the existing revision — so a pipeline can re-submit the same definition freely without accumulating versions.

Initialize the SDK

from nemo_evals.sdk import Evaluator
from nemo_helix_plugin.client.client import NemoClient
client = NemoClient.from_config()
evaluator = Evaluator.from_client(client)
tasks = evaluator.tasks # EvaluatorTasksResource
tasksets = evaluator.tasksets # EvaluatorTasksetsResource

These examples use the default workspace. If your current CLI context uses a different workspace, run nemo config set --workspace default first. To check, run nemo config view.

Manage Tasks

A task scores its output with metrics. Store the metric first, then reference it from the task by workspace/name. See Manage Metrics for the metric classes and options.

from nhx_evals_sdk import ExactMatchMetric
# Store a metric the task will reference.
evaluator.metrics.create(
"answer-exact-match",
metric=ExactMatchMetric(reference="{{item.expected}}", candidate="{{item.output}}"),
)

Send the task as a TaskInput (the authorable subset of a task) and address it by name on create. Reference the stored metric with a MetricRef (workspace/name, or a bare name resolved against the task’s workspace). The service returns the stored Task.

from nemo_evals.api.schemas import EvaluatorTaskDefinition, MetadataItem, MetricRef, TaskInput, TaskInputs
france_task = TaskInput(
spec=EvaluatorTaskDefinition(
kind="evaluator",
intent="Answer the user's geography question with the capital city.",
inputs=TaskInputs(instruction="What is the capital of France?"),
reference={"expected": "Paris"},
metrics=[MetricRef("default/answer-exact-match")],
),
metadata=[MetadataItem(key="suite", value="geography")],
)
japan_task = TaskInput(
spec=EvaluatorTaskDefinition(
kind="evaluator",
intent="Answer the user's geography question with the capital city.",
inputs=TaskInputs(instruction="What is the capital of Japan?"),
reference={"expected": "Tokyo"},
metrics=[MetricRef("default/answer-exact-match")],
),
metadata=[MetadataItem(key="suite", value="geography")],
)
stored_france = tasks.create("capital-of-france", task=france_task)
stored_japan = tasks.create("capital-of-japan", task=japan_task)
print(stored_france.id, stored_japan.id, stored_france.spec.metrics)

TaskInput fields

FieldTypeRequiredDescription
specTaskDefinitionYesThe task’s content, discriminated by kind — see below.
metadatalist[MetadataItem]NoKey/value annotations. Keys must be unique.
tagslist[str]NoTags to point at the revision this request publishes. latest is always applied server-side.

Task kinds

A task is an evaluation unit; its kind says which runner executes it. There are two:

  • evaluator — the task’s content is fields you author, scored by platform metrics.
  • harbor — the task’s content is a self-contained Fileset archive, scored by Harbor’s own reward.

Both are stored as the same record type, so a taskset can group them and you manage every evaluation unit in one place regardless of which runner executes it.

EvaluatorTaskDefinition (kind="evaluator"):

FieldTypeRequiredDescription
intentstrYesHuman-readable description of the desired agent behavior.
inputsTaskInputsNoThe task’s recognized input fields. instruction is the agent’s prompt; it falls back to intent when unset.
referencedict[str, Any]NoGrader-only ground truth (held-out tests, expected outputs, rubric data). Surfaced to metrics but never seeded into the agent’s workspace or shown to the agent. Held out from the agent, not from the API.
metricslist[MetricRefOrInline]NoThe metrics that score the task, as MetricRef references (workspace/name) to stored metrics. Pre-built inline metric bundles (MetricInline) are also accepted and are normalized to stored metrics on create.
viewsdict[str, SemanticView]NoOptional reporting views mapping metric outputs into named semantic scores.

HarborTaskDefinition (kind="harbor"):

FieldTypeRequiredDescription
sourceHarborArchiveSourceYesExact fileset_ref, compressed-byte SHA-256 files_hash, kind="fileset-archive", and archive_format="tar-gzip-v1".
harbor_hashHarborTaskHashYesStored-only producer fingerprint, method and Harbor version. Never used for verification or execution.
native_task_idstrYesNative task identity populated by upload and independently verified at registration. Matches saved trial task IDs.
instructionstrNoVerified instruction projection.
configdictNoVerified task.toml projection; excluded from revision identity.
metricslist[MetricRefOrInline]NoAdditional metrics appended to the mandatory HarborRewardMetric. Inline bundles are normalized to stored references. Defaults to [].
viewsdict[str, SemanticView]NoReporting views over the primary Harbor reward and declared additional metric outputs. Defaults to {}.

Harbor scoring always includes HarborRewardMetric; do not include it again in metrics. Custom metrics and views are part of the task revision, so changing them creates a new revision while reusing the archive. See custom Harbor scoring for a registration and submission example.

See Upload and Register Harbor Dataset for details about managing Harbor datasets in NeMo Helix.

A stored task holds metric references only. Any inline metric bundle you pass on create is stored as a content-addressed derived metric, and the task record is normalized to reference it. This is why stored.spec.metrics always comes back as a list of MetricRef references.

Retrieve and list

# Retrieve one task by name (its current content)
task = tasks.retrieve("capital-of-france")
print(task.spec.kind, task.revision, task.tags) # e.g. evaluator 1 {'latest': 1}
# List tasks in the workspace (paginated)
page = tasks.list(page=1, page_size=100, sort="-created_at")
for item in page.data:
print(item.name, item.spec.kind)

sort accepts name, created_at, or updated_at, each optionally prefixed with - for descending order.

Revisions

Publish a new revision

Use replace to publish new content. It creates the task if it does not exist, so a publisher needs no existence check.

revised_task = TaskInput(
spec=EvaluatorTaskDefinition(
kind="evaluator",
intent="Answer the user's geography question with the capital city.",
inputs=TaskInputs(instruction="Name the capital city of France."),
metrics=[MetricRef("default/answer-exact-match")],
),
metadata=[MetadataItem(key="suite", value="geography")],
)
updated = tasks.replace("capital-of-france", task=revised_task)
print(updated.revision) # 2

Submitting content identical to the current revision publishes nothing and returns the existing revision — but any tags in the body are still applied, which is how you tag a revision after the fact.

List revisions

Each entry carries the content_hash used to pin a reference.

page = tasks.list_revisions("capital-of-france")
for revision in page.data: # newest first
print(revision.revision, revision.content_hash, revision.tags)

Read a specific revision

Pass a content digest or a tag. This returns the content as published, not the current content.

# Revisions come back newest-first and paginated, so index by ordinal rather than by position —
# `data[-1]` is only the oldest entry on the page you happen to have fetched.
page = tasks.list_revisions("capital-of-france")
digest = next(revision.content_hash for revision in page.data if revision.revision == 1)
original = tasks.retrieve("capital-of-france", revision=digest) # revision 1, as published
current = tasks.retrieve("capital-of-france") # revision 2, the current content
assert original.revision == 1 and current.revision == 2
assert original.spec.inputs.instruction != current.spec.inputs.instruction

Tag a revision

A tag is a mutable pointer to a revision — useful for marking one as reviewed or approved after it has been evaluated. Read it back with tag=, the counterpart to revision=:

tasks.tag("capital-of-france", tag="blessed", revision=digest)
blessed = tasks.retrieve("capital-of-france", tag="blessed")

revision= takes a content digest and tag= takes a tag name. They select the same thing two ways, so pass one or the other — passing both raises ValueError.

A tag names exactly one revision. Re-tagging moves the pointer rather than adding a second one, so retrieve(tag=...) always resolves to a single revision — there is no way for two revisions to share a tag.

latest is managed automatically and always names the most recently published revision; it cannot be moved by hand. A tag name may not be empty, and may not look like a content digest (64 hexadecimal characters) — such a tag could be stored but never resolved, because a digest-shaped reference is looked up as a digest rather than as a tag.

Manage Tasksets

A taskset references existing tasks by workspace/name. All referenced tasks must already exist when the taskset is created; a missing or duplicate reference is rejected.

Member references are resolved to an exact revision when the taskset is stored. You may submit a bare name (capital-of-france), a tag (capital-of-france#latest), or a digest — what gets stored is always workspace/name#<digest>. This is why a stored taskset keeps naming the same content even after a member task publishes something new, and it is what makes a suite reproducible.

from nemo_evals.api.schemas import TaskRef, TasksetInput
taskset_input = TasksetInput(
description="Geography questions for validating the agent.",
tasks=[
TaskRef("default/capital-of-france"),
TaskRef("default/capital-of-japan"),
],
)
stored_taskset = tasksets.create("geography-suite", taskset=taskset_input)
print(stored_taskset.tasks)
# ['default/capital-of-france#a1b2...', 'default/capital-of-japan#c3d4...']

TasksetInput fields

FieldTypeRequiredDescription
descriptionstrNoHuman-readable description of the grouping.
taskslist[TaskRef]NoReferences to member tasks (workspace/name, or bare name within the same workspace), optionally pinned with #<tag-or-digest>. Each is resolved to an exact digest when stored. Set semantics — duplicates rejected.
metadatalist[MetadataItem]NoKey/value annotations. Keys must be unique.
tagslist[str]NoTags to point at the revision this request publishes. latest is always applied server-side.

Retrieve and list

stored_taskset = tasksets.retrieve("geography-suite")
page = tasksets.list(page=1, page_size=100, sort="name")
for item in page.data:
print(item.name, len(item.tasks))

Tasksets carry the same revision surface as tasks — replace, list_revisions, tag, and retrieve(revision=...) / retrieve(tag=...):

# Re-resolving membership after a member task published new content cuts a new revision.
tasksets.replace("geography-suite", taskset=taskset_input)
for revision in tasksets.list_revisions("geography-suite").data:
print(revision.revision, revision.content_hash)

Re-submitting the same member names can still publish a new revision. Members are re-resolved on every write, so if a member task published in the meantime the grouping now names different content and genuinely differs. A taskset’s identity is the exact revisions it names, not the names alone.

Member order, by contrast, is not part of that identity: membership is a set, so it is stored in a canonical order and reordering the same members publishes nothing.

Deleting a taskset does not delete its member tasks — a taskset only holds references.

Filter tasks and tasksets

Both list() methods take filter arguments, so you can find tasks and tasksets by what they contain rather than paging through everything in a workspace. Arguments combine with AND.

# Tasks annotated with suite=geography
page = tasks.list(metadata={"suite": "geography"})
# Evaluator tasks that use a stored metric, and carry the "blessed" tag
page = tasks.list(kind="evaluator", metric="answer-exact-match", tag="blessed")
# Case-insensitive substring search on an evaluator task's intent
page = tasks.list(intent_contains="capital city")
# Tasksets that include capital-of-france at any revision
page = tasksets.list(task="capital-of-france")
# Case-insensitive substring search on a taskset's description
page = tasksets.list(description_contains="geography")
ResourceArgumentMatches
TaskskindTask kind: evaluator or harbor.
Tasksnative_task_idA Harbor task’s native task ID.
Tasksintent_containsEvaluator tasks whose intent contains the text (case-insensitive).
TasksmetricTasks that use the stored metric workspace/name.
TasksetstaskTasksets with the member task workspace/name at any revision, or workspace/name#<digest> at that revision only.
Tasksetsdescription_containsTasksets whose description contains the text (case-insensitive).
BothtagRecords carrying the revision tag, such as blessed or v1.2.
BothmetadataRecords whose metadata has every given key with the given value.

The async resources take the same arguments.

Keep these rules in mind:

  • Metadata matches one annotation. metadata={"suite": "geography"} matches only an annotation whose key is suite and whose value is geography. A key and value on different annotations don’t match.
  • Bare references use the request’s workspace. metric="answer-exact-match" and task="capital-of-france" match default/... when listing in default, as references do everywhere else.
  • Members are matched by name or by digest, never by tag. Because stored taskset members are pinned to a digest, a #tag fragment can never match and returns 400. Use workspace/name to match any revision.

Filter over REST

The REST endpoints accept the same filters through the standard filter parameter. See Filtering for the bracket, text, and JSON forms.

EndpointFieldOperatorsExample
GET /taskskind, native_task_id, intentAny?filter[intent][$like]=capital
GET /tasksmetrics$eq, $in?filter[metrics]=default/answer-exact-match
GET /tasksetsdescriptionAny?filter[description][$like]=geography
GET /tasksetstasks$eq, $in?filter[tasks]=default/capital-of-france
Bothtags$eq, $in?filter[tags][$in]=blessed,v1.2
Bothmetadata.<key>$eq, $in, $nin, $like, $startsWith, $endsWith, $lt, $lte, $gt, $gte?filter[metadata.suite]=geography or ?filter[metadata][suite][$startsWith]=geo
Bothname, created_at, updated_atAny?filter[created_at][$gte]=2026-01-01

Bracket values arrive as strings. To match a non-string metadata value, such as a number or a boolean, use a JSON filter such as ?filter={"metadata.verified": true}. The SDK always sends JSON.

Run an evaluation over a taskset

An agent evaluation is submitted with an AgentEvalInputSpec, whose tasks field is either an inline list of tasks or a reference to a stored taskset. Referencing a taskset lets you keep the task definitions in one place and evaluate the whole set by name, instead of inlining every task on each run.

from nemo_evals.api.schemas import TasksetRef
from nemo_evals.jobs.agent_spec import AgentEvalInputSpec, ModelTarget
from nhx_evals_sdk.values import Model
# Instead of inlining AgentEvalTaskInput objects, point `tasks` at a stored taskset.
input_spec = AgentEvalInputSpec(
tasks=TasksetRef("default/geography-suite"),
target=ModelTarget(
model=Model(url="https://integrate.api.nvidia.com/v1", name="nvidia/nemotron-3.5-lightning-30b-a3b"),
),
)

When the job runs, the taskset reference is resolved like this:

  • The taskset revision the ref names is loaded — the current one unless the ref pins a revision (see Pin the taskset itself).
  • Each member of that revision is loaded at the revision pinned in the taskset, not the task’s current tip.
  • Metric references on those members are hydrated into runnable metrics, the same as for inline tasks.
  • Re-running the same taskset therefore evaluates the same content, even if a member has been republished since.
# What is stored on the taskset (digests fixed at create/replace time)
geography-suite@1
└─ tasks:
- default/capital-of-france#aaa111... ← revision 1 content
- default/capital-of-japan#bbb222...
# Later: the member task publishes new content
capital-of-france tip → revision 2 (#ccc333...)
# Re-grade still uses the taskset pins, not the tip
JobRun A ──TasksetRef("geography-suite")──► load #aaa111..., #bbb222...
JobRun B ──TasksetRef("geography-suite")──► load #aaa111..., #bbb222... ← same content

To evaluate the new task content, publish a new taskset revision (for example tasksets.replace(...)) so membership is re-resolved to the newer digests.

Submit this spec as the agent-evaluate job input; see Agent Evaluation for the full run, target, and results flow.

The inline form remains available for one-off tasks — swap tasks=TasksetRef(...) for tasks=[AgentEvalTaskInput(...), ...].

Pin the taskset itself

A bare TasksetRef expands the taskset’s current revision, so it follows the suite forward every time the taskset is republished. Add a #<tag-or-digest> fragment to pin the grouping too:

# The digest lives on the revision, not on the taskset record — revisions come back newest first.
current = tasksets.list_revisions("geography-suite").data[0]
# Follows the suite forward — the next `replace` is picked up on the next run.
tracking = TasksetRef("default/geography-suite")
# Frozen: this exact membership and content, regardless of later `replace` calls.
pinned = TasksetRef(f"default/geography-suite#{current.content_hash}")
# A tag works the same way, and can be moved deliberately when you bless a new suite.
blessed = TasksetRef("default/geography-suite#blessed")

What each form is stable against:

A member task republishesThe taskset is replaced
TasksetRef("suite")unaffectedfollows the new revision
TasksetRef("suite#<digest>")unaffectedunaffected

Every taskset revision pins its members by digest, so neither form is disturbed when a member task publishes new content on its own — that is the guarantee stored membership buys you.

The two differ on replace. A bare ref tracks the taskset’s own revisions, and members are re-resolved on every write — so a replace can change both which tasks are named and the content they resolve to, even when the submitted member names were identical. Pin the taskset when a benchmark number has to stay comparable across that.

A fragment that no longer resolves fails the evaluation rather than falling back to the current revision.

A member’s grader-only reference (held-out ground truth) is loaded from the pinned revision along with the rest of its content, so a taskset-driven run grades against the ground truth that revision fixed. Because reference is covered by the revision digest, changing it publishes a new revision — a pin fixes the grading, not just the prompt.

Clean Up

Delete the taskset before its member tasks, then remove the metric used by the tasks:

tasksets.delete("geography-suite")
tasks.delete("capital-of-france")
tasks.delete("capital-of-japan")
evaluator.metrics.delete("answer-exact-match")

Async usage

AsyncEvaluator exposes the same surface; await each call.

import asyncio
from nemo_evals.sdk import AsyncEvaluator
from nemo_helix_plugin.client.client import AsyncNemoClient
async def main() -> None:
async with AsyncNemoClient(base_url="http://localhost:8080", workspace="default") as async_client:
evaluator = AsyncEvaluator.from_client(async_client)
page = await evaluator.tasks.list()
for item in page.data:
print(item.name)
asyncio.run(main())

Workspaces and projects

Every method accepts an optional workspace argument that overrides the client’s default workspace. On create, an optional project argument associates the task or taskset with a project. When you omit workspace, the client’s configured workspace is used.

REST API

The SDK resources are a thin client over the Evals plugin REST API, mounted under /apis/evals/v2/workspaces/{workspace}:

MethodPathDescription
GET/tasksList tasks (paginated, filterable).
POST/tasks/{name}Create a task and publish revision 1.
PUT/tasks/{name}Replace a task’s content and publish; creates it if absent.
GET/tasks/{name}Retrieve a task’s current content.
GET/tasks/{name}/revisionsList published revisions (paginated, newest first).
GET/tasks/{name}/revisions/{revision}Retrieve content as of a digest or tag.
PUT/tasks/{name}/tags/{tag}?revision=Point a tag at an existing revision.
DELETE/tasks/{name}Delete a task and all of its revisions.
GET/tasksetsList tasksets (paginated, filterable).
POST/tasksets/{name}Create a taskset and publish revision 1.
PUT/tasksets/{name}Replace a taskset’s membership and publish; creates it if absent.
GET/tasksets/{name}Retrieve a taskset’s current membership.
GET/tasksets/{name}/revisionsList published revisions (paginated, newest first).
GET/tasksets/{name}/revisions/{revision}Retrieve membership as of a digest or tag.
PUT/tasksets/{name}/tags/{tag}?revision=Point a tag at an existing revision.
DELETE/tasksets/{name}Delete a taskset and all of its revisions.

Edge Cases

PUT distinguishes its two outcomes by status: 201 when a new revision was published, and 200 when the submitted content was already the current revision and nothing was cut. The rest:

CaseStatusBehavior
PUT with new content201A revision is published and latest moves to it.
PUT with content identical to the current revision200Nothing is published; any tags in the body are still applied.
PUT with taskset members reordered200Membership is a set, so a reordering is not a content change.
PUT reverting to older content201The record genuinely changed, so it publishes a new ordinal rather than reusing the old one.
PUT re-resolving a member that has since republished201A taskset’s identity is the exact revisions it names.
Two identical PUTs racing201 + 200One publishes; the other adopts its revision rather than cutting a duplicate.
POST on an existing name409Create is strict; use PUT to upsert.
PUT losing a race with a concurrent write409Retry against the current state.
Invalid metric reference (task)422Rejected at validation.
Missing or duplicate task reference (taskset)422Members must exist, and must resolve to distinct tasks.
Reserved or malformed tag name422latest cannot be moved by hand; a digest-shaped tag is refused.
Retrieving or deleting an unknown name or revision404Applies to both records and revisions.
Listing with an unknown filter field400Includes kind on /tasksets, which have no kind.
Listing with an unsupported operator on metrics, tasks, tags, or metadata400metrics, tasks, and tags accept only $eq, and $in with at least one value; metadata.<key> accepts the comparisons listed under Filter over REST.
Listing tasksets with a tasks filter pinned by tag400Members are stored pinned to a digest, so a tag could never match.
TasksetRef pinning a revision that no longer resolvesjob failsExpansion refuses rather than falling back to the current revision.
DELETE on a task a taskset pins204Not prevented; the taskset’s reference dangles and fails on read.