Milpa Agent
Long-running coding sessions for the Milpa PHP framework.
An agent that answers one question needs nothing. An agent that works for an hour needs the session to be a thing that exists — something you can pause, resume, audit and stop.
That is all this package is: a session is an event-sourced stream whose events are the
conversation, whose human gates are the permissions, and whose state carries the plan. It brings no
LLM client (milpa/ai-gateway has one), no tools (milpa/tool-runtime has those), and no storage
(milpa/event-store has that). It brings the vocabulary that turns those into a working day.
composer require milpa/agentuse Milpa\Agent\SessionStore;
use Milpa\EventStore\FileEventStore;
$sessions = new SessionStore(new FileEventStore(__DIR__ . '/var/agent-sessions.jsonl'));
$sessions->start('migrate-inventory', 'move the Inventario plugin to sqlite');
$sessions->recordTurn('migrate-inventory', 'user', 'start with the repository');
$sessions->recordToolCall('migrate-inventory', 'make', ['what' => 'entity'], 'ok: created');
// A different process, tomorrow:
$session = $sessions->load('migrate-inventory');
$session->goal; // 'move the Inventario plugin to sqlite'
$session->window(); // what to send the model: summary + recent turns
$session->isRunnable();Because what matters about a long session is both where it ended up and how it got there. A
row with current_state answers the first and erases the second on every UPDATE — and the second
is exactly what someone wants the next day: which permission was granted and when, what the agent
asked, what it was told, at which step it went wrong. With a stream, "the agent ran forty steps on
its own" is a verifiable claim instead of a hope.
It also makes the rest possible. Resuming is replaying. Compacting is appending a summary without
losing the turns it summarises — window() shortens what the model sees while the stream keeps
everything, so the evidence behind a decision survives the context that produced it. A pending
question is an event without its pair, not a flag someone has to remember to clear.
Nothing is ever rewritten. Revoking a permission does not delete the grant; it appends on top of it. A log you can edit stops being useful for the one thing a log is for.
goal / mode |
why it was opened, and how much autonomy it runs with |
turns |
the conversation, including tool calls — resuming without them means repeating work already done |
plan / todos |
the plan lives in the stream, not in the prompt: one that only exists inside the context is lost at the first compaction, which is exactly when it matters most. stateBriefing() renders it back into the window, so the agent rereads what it wrote — a plan you can only audit is half a feature |
permissions |
consented per operation and per session — "yes to make, in this session" is a sentence someone can evaluate; "yes to whatever the agent decides" is not |
question |
while one is open the session is not runnable. An agent that "asks" and proceeds on its assumption did not ask, it narrated |
decisions |
what a human resolved when the session stopped to ask — with who resolved it, and whether that identity was verified |
summary / compactedThrough |
what the model is spared, never what the log forgets |
SessionPolicy is the piece that decides how far an automatic process may go over someone's code, so
it is written to be argued with by reading it: it calls nobody, appends nothing, and takes three facts
about an operation.
$policy->decide($session, 'make', mutating: true, requiresSignature: false);
// Allow | AskPermission | RequireSignatureThe order of the rules is the rule. The signature is evaluated before the granted permission and
before the mode. Evaluated later, a grant on that operation — or an auto mode — would let it
through, and there goes the only gate that names the concrete call instead of the category.
Reading is never asked. An agent you must authorise for every query is not a supervised agent, it is a useless one — and the attention you spend on what does not matter is the attention you stop spending on what does.
A question can carry an expiresAt, and passing it ends the session with a reason:
$store->ask($id, new PendingQuestion('perm:make', '…?', ['yes','no'], expiresAt: $deadline));
$store->expireIfDue($id, new DateTimeImmutable()); // true when it closed the windowExpiry is declared, not derived. It could be computed by comparing the deadline with the clock,
and that is exactly why it is appended: a derived expiry leaves no trace of when it was noticed, and
a session that died of silence is precisely the case where somebody will want to know. The clock did
not close the session — expireIfDue() did, at a concrete instant, and left a fact.
The event is called session.answer_window_closed and not "question expired" on purpose. The
question did not expire: it is still the same question and still valid. What ran out is the
authority to answer it within this session.
It ends the whole session rather than just the question, because the question exists so the agent can continue and without an answer it cannot: closing it and leaving the session alive would send the agent to ask the same thing again or — worse — to proceed without the permission it was waiting for.
expiresAt: null still means "waits indefinitely", and that remains a valid choice. There is no
default window: how long a human has to answer depends on who operates the agent, and a number
invented in this package would kill sessions belonging to people who never chose it.
answer() takes an optional Principal, and the principal carries whether its identity was
verified:
$store->answer($id, 'perm:make', 'yes', new Principal('actor:member:42', verified: true));
$store->answer($id, 'perm:make', 'yes', Principal::fromTerminal($user, $host)); // verified: falseThe two sources are not worth the same, and merging them would be worse than storing nothing. An authenticated context has a credential behind it; a terminal reports the OS user, which anyone holding that terminal can be. Recording the second as if it were the first would manufacture a chain of custody that does not exist — "rod authorised it" when what is known is "whoever had rod's machine authorised it".
Replay never raises trust: anything that does not explicitly say verified: true reads as unverified.
And null — nobody said who — stays null; sessions recorded before this existed are not given an
invented principal.
Since 0.4 a PendingQuestion carries reason: a stable code (permission, signature,
target_not_named) next to the human-readable text. The text gets rewritten and translated; the
code does not — a projection counting how many pauses each authority produced must never parse
prose. And when a question is answered, the resulting decision inherits reason and why from
the question that produced it, so a confirmation can be consumed as data: "this operation, over this
target, was confirmed by the human" is readable from the fact alone. That is what closes the loop —
a yes to "did you mean HelloPlugin?" names the target, the retry passes, and a yes to one target
names no other.
When a session outgrows its window, Compactor appends a summary of the old turns and keeps the
recent ones intact. The window shrinks; the history does not.
$compactor = new Compactor(maxTurns: 40, keepRecent: 12);
$compactor->compactIfNeeded($sessions, $session); // returns the summary it appended, or nullTwo details that are the whole design:
The threshold counts turns not yet summarised, not all of them. Counting the total would make a long session compact on every turn — the total never goes down — appending one summary per turn, each hiding the last.
Recent turns survive intact because a summary answers "what has happened" and not "what were we doing a minute ago", and the second is what the model needs to take the next step. Summarising everything leaves a session that knows its history and not its place — visible as an agent that, right after compacting, repeats work or asks something it just got answered.
The default FactualSummarizer does not call the model. For a coding session what compaction
loses is not nuance, it is facts: the goal, which tools ran, what was authorised, what the human
decided, what is still pending. All of it is already in the stream, exact, and deriving it costs no
call and cannot hallucinate. And a made-up summary is worse than expensive — it gets appended as
what happened, and from then on the model works from a version of the session nobody wrote. Swap in
your own Summarizer if you want prose.
ask pauses before anything that mutates. acknowledge announces and continues. auto runs to the
end.
What no mode can skip is a signature. An operation declaring requiresConfirmation demands
consent that names that call with those arguments, and pre-approving "whatever the agent decides"
is signing a blank cheque. auto means don't ask me about the reversible — never don't ask me.
That line lives in the type and not in a config file, because a line you can move with an environment
variable is not a line.
Apache-2.0 © Rodrigo Vicente - TeamX Agency
Milpa is designed, built, and maintained by Rodrigo Vicente - TeamX Agency.