Skip to content
The agent loop

The agent loop

kslate never calls your agent. Your agent asks the gateway whether anything changed, and acts if so. This page shows that loop in the three forms the repository ships:

  • one cycle you schedule
  • a loop that polls a whole class
  • a sidecar for agents you cannot change

One cycle

A cycle reads the record, decides, and writes with based_on. If the write is rejected because something moved, it reads and decides again.

    flowchart LR
  get["get the record"] --> decide["decide()<br/>(your model call)"]
  decide -->|"writes"| write["write with based_on"]
  write -->|"accepted"| done(["done"])
  write -->|"STALE or STALE_PREMISE"| get
  decide -->|"nothing to do"| done
  

This is what run(agent, client, record=...) does. It is also what your first agent ran. Use it when something else decides when your agent runs: a cron job, a webhook, a workflow step. You call run() and it returns when the cycle is done.

run(agent, client, record="incidents/inc-7")

If you want to see the loop rather than have run() hide it, here it is with the client’s Session, which remembers what you read and fills in based_on:

# The loop run() does for you, written out, so you can see the rejection.
import time
from kslate import Client, Session, StalePremiseError

RECORD = "incidents/inc-9"
session = Session(Client("http://127.0.0.1:8080", api_key="rem-token"))

while True:
    rec = session.get(RECORD)
    plan = rec.fields["planned_action"]
    print(f"read planned_action v{plan['version']}: {plan['value']!r}")
    time.sleep(3)  # your model call goes here
    try:
        session.append(RECORD, "actions_taken", f"executed: {plan['value']}", evidence="kubectl output")
        print("write accepted")
        break
    except StalePremiseError as e:
        for m in e.moved:
            print(f"rejected: {m.field} moved v{m.your_version} -> v{m.current_version} by {m.current_author}")
        # fall through: read again, decide again

Run against the same two-agent setup as the first agent:

read planned_action v1: 'restart payments-api pods'
rejected: planned_action moved v1 -> v2 by apikey:diagnoser
read planned_action v2: 'roll back api deployment'
write accepted

Many records, many copies

Most agents watch a whole class, not one record, and you run more than one copy. For that, run() has a class-scoped mode:

run(
    agent,
    client,
    class_name="incidents",
    filters={"status": "open"},
    claim_field="remediation",
    lease_seconds=300,
    concurrency=4,
    poll_interval=5.0,
)

Each pass:

  1. query the class for records matching filters. poll_interval is how long to wait when there is nothing. This is the only polling your agent does. The worker, the policy file watcher and the sidecar each poll on their own schedule.
  2. For each record, claim the task field. If another copy already holds it, the gateway returns HELD and this copy skips the record. That is how copies share work without talking to each other.
  3. Run one cycle on the record: read, decide, write with based_on.
  4. If the agent has a build_checkpoint function, save a checkpoint.
  5. Release the claim and move on. If the release fails, the lease lapses on its own when it runs out. A copy that crashes mid-cycle never reaches this step, so its lease lapses the same way. The next copy to claim that record takes over.

The loop works up to concurrency records at a time, then waits poll_interval and starts again.

    flowchart TB
  q["query the class for matching records"] --> c["claim the task on one record"]
  c -->|"HELD"| skip["skip it, another copy has it"]
  c -->|"got it"| cyc["one cycle: get, decide, write"]
  cyc --> cp["checkpoint"] --> rel["release the claim"] --> wait["wait poll_interval"]
  skip --> wait
  wait --> q
  

You can run as many copies as you like, as long as each presents its own identity. A claim from the identity that already owns the task is a re-claim, so copies sharing one API key or one ServiceAccount all claim successfully and all act on the same record. One identity, not one process has the shape of a role that covers a fleet.

A copy that dies holding a claim loses it when the lease runs out, and the next copy to claim that record takes over. See Claims and leases.

run() renews the lease while decide runs and stops renewing before it writes. Each write names the claim’s version in based_on as a fence. If the lease was lost in the meantime, the gateway refuses the write with STALE_PREMISE and the cycle returns False. run() paces its renewals from the lease the gateway actually granted, lease_expires_at on the claim response. A class with a short default lease is renewed in time whether or not you pass lease_seconds.

Calling a framework from inside the loop

If something else already implements your agent — a graph, a runtime, a callable — and nothing schedules it, this loop is the scheduler. Call the framework from inside decide and return what it produced.

def decide(fields):
    result = agent_runtime.invoke(plan=fields["planned_action"]["value"])  # your framework
    return [Write(op="append", field="actions_taken",
                  entry=result.action, evidence=result.evidence)]


run(Agent(decide=decide), client,
    class_name="incidents", filters={"status": "open"},
    claim_field="remediation", lease_seconds=300, concurrency=4)

Each copy claims one open incident at a time. The call into your framework may take minutes: the loop renews the lease while it runs and drops the result if the lease is lost.

This works when the framework’s call is safe to make twice, because a rejection makes the loop call it again. When it is not, pass max_attempts=1 and handle the rejection yourself — handling rejections covers that case.

Agents you cannot change

If your agent is a container you do not control, run kslate-reconciler next to it. On kagent 1.0, where there is no sidecar slot, run it as its own Deployment with --transport kagent-gateway. It is the same loop as above. Each cycle reads the record and claims the task. It calls your container once over HTTP, then writes back what the container returned, with based_on filled in. It renews the lease during the call and checkpoints after. The kagent guide shows the wiring and the exact request and response your container handles.

If your read and your write are in different steps

run() does one bounded cycle: read, call you, write. A graph that reads in one node and writes in a later one has no single decide to hand over. Use a Session directly — it is what run() uses inside:

from kslate import Client, Session

s = Session(Client("http://127.0.0.1:8080", api_key="rem-token"))
rec = s.get(RECORD)                                        # in one step
s.append(RECORD, "actions_taken", action, evidence=out)    # in a later one

get() remembers every version it read and append() sends them as based_on, however much framework machinery sits in between. Keep the record out of the framework’s own state object: a copy there goes stale the moment another agent writes.

What each call costs

kslate pushes nothing, so the loop’s reads are how an agent learns that anything changed. What each call does in Postgres:

CallWhat it does
get, queryOne read. No history row. With a scope header, the read is also recorded for inference, and the worker deletes those records after an hour.
claim, renew, releaseOne write under the record’s row lock, and one history row each. run() renews at a third of the granted lease while decide runs.
put, appendOne write under the record’s row lock, and one history row, applied or rejected.
checkpointOne write. Earlier checkpoints are kept.

What follows from that:

  • Poll with query on the class, not get on each record. One query returns a page of records with every field’s version, so the loop touches only the records that need work.
  • Make filters narrow enough that a match means work. run() claims every record query returns, and each claim is a history row. The sidecar reads first and claims only when its watch field has moved.
  • Set poll_interval from how fast the fields you watch change. Every copy polls on its own, so the read rate grows with the number of copies.
  • The lease sets the renewal rate. A lease two to three times one step, renewed at a third, is a few history rows per step.
  • Many copies writing one record serialise on that record’s row lock. That is the case the deferred load test measures. No number for it has been published.

What the loop does on each error

The gateway saysrun() doesWhy
STALE or STALE_PREMISEReads the record again and calls decide again, up to max_attempts (default 5)Your decision was based on something that changed. Only a new decision is safe.
STALE or STALE_PREMISE when decide returned more than one writeRaises immediatelyAn earlier write in the list may already have landed. Re-deciding could submit it twice. You inspect the record yourself.
HELD on claimSkips the recordAnother copy is working on it.
Anything else (SCOPE_DENIED, EVIDENCE_REQUIRED, …)Raises APIErrorThese are bugs in your class or your agent, not races.

Handling rejections shows what each error carries and how to handle it in your own code.

Where your model goes

Inside decide. It receives the record’s fields and returns writes. It should not call the gateway, sleep waiting for other agents, or retry on its own. If it needs more information, return no writes and let the next cycle read again.

Keep decide free of kslate: no versions, no based_on, no error handling. The loop owns those. That is what lets the same decide run in single-record mode, class-scoped mode, or behind the sidecar.

Saying which call produced a write

A write can carry an invocation object. It is a small JSON object that says which call produced the write. kslate-reconciler fills it in from the transport’s task and context ids. A hand-written loop passes its own with invocation=.

{
  "invocation": {
    "agent": "kagent:kagent/remediator",
    "task_id": "01a1245d-d1ce-7047-bfed-d09ccd907278",
    "context_id": "01a1247b-356c-7ef9-9648-4cc792026470"
  }
}

kslate stores it and returns it unchanged. It never reads a key out of it. The limit is 1 KiB. A larger object is refused as MALFORMED. Put identifiers in it, not logs.

It is separate from evidence. Evidence says what the decision relied on, and blame follows it. The invocation says which call produced the write.

Nothing verifies it. The writer could put anything in it, so blame labels it writer-asserted:

identity: sa:kagent/kslate-remediator (pod-bound)
invocation (writer-asserted): agent=kagent:kagent/remediator task_id=01a1245d-...

The first line is what kslate verified. The second is what the writer says. See identity strength.