Your first agent
You write one function. It receives the record’s fields and returns what should be recorded. The client does the reading, the writing and the bookkeeping.
If you already have an agent, this is still the page: your code goes inside that function, and nothing about it changes.
Before you start
A gateway, which the install starts for you:
make runIt serves the starter class and writes two keys: diag-token for the
diagnoser, rem-token for the remediator.
And the client, which is not on PyPI yet and has no dependencies:
pip install -e client/pythonThe starter class, incidents, has a planned_action field that the
diagnoser writes and an actions_taken field that the remediator
appends to. This agent plays the remediator. Give it a plan to act on:
curl -s -X PUT http://127.0.0.1:8080/v1/records/incidents/inc-7/fields/planned_action \
-H 'Authorization: Bearer diag-token' \
-d '{"value":"restart payments-api pods","expected_version":0,"evidence":"api pods in CrashLoopBackOff"}'The agent
Save this as first_agent.py:
from kslate import Agent, Client, Write, run
RECORD = "incidents/inc-7"
def decide(fields):
plan = fields["planned_action"]
print(f"plan is {plan['value']!r} at v{plan['version']}")
action = f"executed: {plan['value']}" # your agent goes here
return [Write(op="append", field="actions_taken",
entry=action, evidence="kubectl rollout restart deploy/payments-api")]
client = Client("http://127.0.0.1:8080", api_key="rem-token")
run(Agent(decide=decide), client, record=RECORD)
print("written")python3 first_agent.pyplan is 'restart payments-api pods' at v1
writtenThat is the whole integration. What each part is:
decide(fields)is your agent. It gets every field of the record as a dict, in the shape the gateway returns, and returns a list of writes — or an empty list when there is nothing to do. Your model call goes inside it.Writeis one write you want made.opisputorappend. Anappendtakesentryandevidence; aputtakesvalue, and the client fills inexpected_versionfrom what it read.Clientholds the gateway URL and your credential. Here the identity isapikey:remediator, which theincidentsclass allows to append toactions_taken.Agentis configuration, not a class to subclass.decideis the only field it needs. Itsreadsandwriteslists document intent for a reader of your code; the gateway enforces scope, and the client does not keep a second copy of that check.run()reads the record, callsdecide, and submits the writes withbased_onset to the versions it read. You write no version check, nobased_onlist and no retry loop.
What landed
KSLATE_APIKEY=rem-token bin/kslate get --server http://127.0.0.1:8080 incidents/inc-7incidents/inc-7 (record_version 2)
actions_taken v1 (1 entries)
#1 apikey:remediator "executed: restart payments-api pods"
planned_action v1 apikey:diagnoser "restart payments-api pods"Your entry carries who wrote it. It also carries the evidence you gave
and the version of planned_action it was based on, which
blame shows.
Make the gateway reject a write
A second agent can change the plan while your agent is deciding. The gateway then rejects the write your agent was about to make, because it was based on a plan that is no longer current.
Save this as rejected_agent.py. The decide function is the one you
already wrote. What is new is a second client, holding the diagnoser’s
key, that changes the plan on the first call:
from kslate import Agent, Client, Write, run
RECORD = "incidents/inc-7"
SERVER = "http://127.0.0.1:8080"
diagnoser = Client(SERVER, api_key="diag-token")
calls = 0
def decide(fields):
global calls
calls += 1
plan = fields["planned_action"]
print(f"decide #{calls}: planned_action v{plan['version']} {plan['value']!r}")
if calls == 1:
# Stands in for the other agent. A real diagnoser writes this on
# its own schedule. Here it is timed so the rejection is certain.
diagnoser.put(RECORD, "planned_action", "roll back api deployment",
expected_version=plan["version"],
evidence="api logs show config parse error at 14:02:11")
return [Write(op="append", field="actions_taken",
entry=f"executed: {plan['value']}", evidence="kubectl output")]
client = Client(SERVER, api_key="rem-token")
run(Agent(decide=decide), client, record=RECORD)
print("written")python3 rejected_agent.pydecide #1: planned_action v1 'restart payments-api pods'
decide #2: planned_action v2 'roll back api deployment'
writtenYour function ran twice, on two different plans. What happened between those two lines:
decidereadplanned_actionat v1 and returned an append.run()sent it withbased_on: planned_action@1.- The plan was at v2 by then. The gateway rejected the append with
STALE_PREMISE, and named the field that moved, its new version and who moved it. run()read the record again and calleddecidewith v2. That append carriedbased_on: planned_action@2, and the gateway applied it.
Nothing recorded an action against a plan that had been replaced. Your
function never saw the rejection, and you wrote no retry loop. run()
re-reads and re-decides up to five times, which max_attempts sets.
decide returns writes instead of performing them, so a second call
costs one more decision. If your function also acts — restarts
something, calls an API, spends money — a second call acts again. Pass
max_attempts=1 and the rejection reaches your code instead. When a
field changes mid-decision has that
code, and the other four errors a client raises.
Which record
incidents/inc-7 is an id you choose. Most agents are triggered by
something that already names the work — a webhook, a queue message, a
ticket — so use that identifier, and one that survives your own retries
so a retry lands on the same record. The first write creates the record;
nothing creates it in advance.
If nothing names it, ask:
res = client.query("incidents", filters={"status": "open"})
for r in res["records"]:
print(r["record"])Next
- The agent loop runs this over a whole class, with many copies sharing the work.
- When a field changes mid-decision covers the rest of the rejection you just saw: the five errors a client raises, and what to do when your agent acts as it decides.