Skip to content

Tasks and History

What you'll learn: What a task is, how history enables path-dependent policies, and how task lifecycle and memory management work.


What is a task?

A task is one end-to-end agent execution, identified by a task_id. It represents everything an agent does from start to finish for a single request, conversation turn, or job.

The task's history holds completed steps in completion order, followed by open intents in evaluation order. An intent is an allowed or warned step that has passed evaluate() but has not reached record(). Path-dependent policies see both, so a second preflight can account for a step still running.

Task lifecycle

start_task()
  |
  v
evaluate() -> execute -> record()    (repeat per step)
  |
  v
end_task()  or  error_task()

start_task()

Begins a new task. Returns a task_id. Optionally accepts a caller-provided task_id.

Identifier rules

Identifiers are hashed into the audit trail and used as storage keys, so they follow a fixed grammar. A value outside it is refused (KyvvuValidationError from the kyvvu Python client, ValueError from Behavior, HTTP 422 from kyvvu serve and the platform), naming the field and the rule, for example task_id 'run/42': use letters, digits and . _ : - only (1-100 characters).

Field Rule
task_id, parent_task_id letters, digits and . _ : -; 1-100 characters
action_id letters, digits and . _ : -; 1-255 characters
agent_id letters, digits and . _ : -; 0-255 characters
step_name any text of 1-255 characters with no control characters (U+0000-U+001F, U+007F-U+009F), lone surrogates or Unicode noncharacters
aag_version letters, digits and . + -; 1-64 characters

A task_id you pass to start_task() (or resume_task(), end_task(), add_step(task_id=...)) is refused, never rewritten. Generated ids (UUID4) always comply. Values a framework supplies are adjusted instead, so they never fail your agent: a step name taken from a function or tool name has control characters replaced by U+FFFD and is cut to 255 characters, and a framework id that does not fit becomes x- followed by 32 hex digits of its SHA-256 (the same input always gives the same id; a mapped task id keeps the original as properties.raw.task_id on its task.start).

A framework handler (KyvvuLangChainHandler, KyvvuLangGraphHandler, KyvvuCrewAIListener) opens and closes its own task, and it raises KyvvuConfigError if a task is already active in the current context, so do not call start_task() around a handler. The handler closes only the tasks it opened.

The Python client emits a task.start Behavior that flows through the normal evaluate() path — step_execution policies run; agent_registration policies do not — those are evaluated once, at register_agent(), and never again.

evaluate() / record() cycle

For each step within the task:

  1. evaluate(intended, context) — checks the intended Behavior against policies and history. An allow or warn registers a step.* intent under its action_id. A block on a step.* is recorded by the runner itself, as a blocked step carrying the decision; the caller must not record it again. How a blocked task.* boundary is represented is not part of the 1.0 contract. The runner's evaluate() changes task history, while PolicyEngine.evaluate() and explain() only read it.
  2. The caller executes the step.
  3. record(completed_step) — settles the matching intent by action_id, appends the outcome to completed history, and assigns its seq. A recorded outcome with no open intent is appended as a replayed step.

seq is strictly increasing per (agent_id, task_id) and may have gaps. It says nothing about which of two concurrent steps happened first: do not read happens-before from it. (The runner currently assigns it when a step is recorded; that is an implementation detail, not a promise.) In 1.0 a task is written by the client that opened it; continuing a task elsewhere is not yet defined.

A multi-agent task has a single order only when one client logs it, under one agent_id. Record which sub-agent acted in the step's properties. If several agents log under the same task_id, each is numbered 1, 2, 3, … on its own, and only the advisory timestamp relates one agent's steps to another's.

For concurrent calls on one task, the runner completes each evaluation and intent registration before the next evaluation reads history. Recording an outcome uses the same task lock.

end_task(task_id)

Normal completion. The Python client emits a task.end Behavior through evaluate(), then calls the runner's end_task(). This: - Records each still-open intent with output.status of abandoned, preserving its action_id in the exported trace. These are recorded before the task.end boundary step, so the boundary is the task's last step - Evicts the task's history from memory - Queues the task's buffered steps for delivery to the log endpoint (if configured). A background worker sends them; end_task() does not wait, so a short script calls kv.flush() before it exits

No policies run in end_task() itself — it is cleanup. To enforce task-end invariants, use policies that match on the task.end behaviour type.

error_task(task_id)

Abnormal termination. Same cleanup as end_task(), but emits a task.error Behavior first. History is evicted identically. Calling it for a task that is already closed raises KyvvuConfigError.

A block does not end the task

When a policy blocks a step, on any surface (@kv.step, kv.add_step, the framework handlers), the blocked step is recorded (output.status is blocked), KyvvuBlockedError is raised, and the task stays open. History is kept, so the taint, gate and sequence state the path rules read is intact. The caller chooses what happens next: error_task(), end_task(), or record an approval gate and retry in the same task. (A framework's own error callback, such as LangChain's on_chain_error once the exception propagates, closes the task the handler opened.) A task nobody closes is evicted by the sweeper (below).

Blocked, failed and abandoned attempts remain in history, and open intents appear in the in-flight tail. They can count for rules that forbid or count attempts, but none satisfies a requirement. step_requires_gate and step_requires_predecessor require a successful completed step; step_directly_preceded_by and conditional_successor_required look past attempts to the most recent step that happened. Where any of the three requires a step.gate, the gate must have passed (output.decision: pass; step_requires_gate can ask for another decision with gate_decision): a pending, denied or decision-less gate does not count. For step_directly_preceded_by, a gate still awaiting its decision, a denied gate and a completed gate with no decision are each the immediate predecessor, so each blocks the target even after an earlier approval.

Path-dependent evaluation

Because evaluate() reads the full task history, policies can express path-dependent constraints:

  • "Code execution requires a preceding gate" — step_requires_gate checks whether a completed step.gate with output.decision: "pass" exists in history before step.exec.
  • "External content taint requires a fresh gate" — step_directly_preceded_by checks whether the immediately previous step was a step.gate.
  • "No more than 50 resource calls" — execution_max_steps counts step.resource entries in history.
  • "Forbidden sequence" — sequence_forbidden checks whether a specific ordered sequence has occurred.

The same step can be allowed or blocked depending on what happened earlier. This is the core of the "policies on paths" model.

Memory management

Task history lives in memory, keyed by task_id. Three mechanisms prevent unbounded growth:

Normal termination: end_task(task_id) evicts history from memory and queues the buffered logs for delivery.

Automatic background sweeper: A daemon thread calls sweep_stale_tasks() every KV_SWEEP_INTERVAL_SECONDS (default: 300s / 5 minutes). Tasks older than KV_TASK_MAX_AGE_SECONDS (default: 3600s / 1 hour) are evicted automatically — no manual wiring needed.

Pause-aware eviction: When a task records a task.idle Behavior (e.g., waiting for human input), the sweep TTL clock resets. This prevents idle-but-legitimate tasks from being evicted prematurely. If the task remains idle beyond a full TTL window with no further activity, it is still evicted.

Flush-before-evict: When the sweeper evicts an abandoned task, it attempts a best-effort batch post of the buffered steps to the log endpoint (if KV_SWEEP_FLUSH_ON_EVICT=true, which is the default). This captures partial traces for debugging even when tasks crash. Eviction is local to this runner; its task.error (reason: expired) means the runner stopped tracking the task, not that the task failed.

Config Default Purpose
KV_TASK_MAX_AGE_SECONDS 3600 Max age before a task is considered abandoned
KV_SWEEP_INTERVAL_SECONDS 300 How often the sweeper runs
KV_SWEEP_ENABLED true Set to false to disable automatic sweeping
KV_SWEEP_FLUSH_ON_EVICT true Post partial traces before discarding

A new task_id always starts with a fresh, empty history. Task histories are completely independent — there is no relationship between tasks.

Concurrent tasks

In web servers and concurrent agents, multiple tasks may be active simultaneously. The Python client uses a ContextVar to track the active task_id per execution context (thread or asyncio task). The @kv.step decorator automatically reads the active task ID from this context.

For explicit control:

task_id = kv.start_task()
# ... steps are associated with this task_id ...
kv.end_task()

In a new request or thread, call kv.resume_task(task_id) before a decorated step; programmatic steps and task endings can pass task_id= instead. A context with no selected task never attaches to a lone open task, and a task accepts steps only from the client that started it, between start_task() and end_task() / error_task().


Next steps