PHPackages                             milpa/agent - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Framework](/categories/framework)
4. /
5. milpa/agent

ActiveLibrary[Framework](/categories/framework)

milpa/agent
===========

Long-running coding sessions for the Milpa PHP framework: a session is an event-sourced stream — conversation, plan, todos, granted permissions and human decision points — that survives the process, so an agent can be paused, resumed, audited and stopped.

v0.2.2(today)059↑2900%Apache-2.0PHPPHP &gt;=8.3CI passing

Since Aug 1Pushed todayCompare

[ Source](https://github.com/getmilpa/agent)[ Packagist](https://packagist.org/packages/milpa/agent)[ RSS](/packages/milpa-agent/feed)WikiDiscussions main Synced today

READMEChangelog (5)Dependencies (6)Versions (5)Used By (0)

 [   ![Milpa](https://raw.githubusercontent.com/getmilpa/core/main/art/lockup/milpa-lockup-v-color-light.svg)  ](https://github.com/getmilpa)

 **Milpa Agent**
 *Long-running coding sessions for the Milpa PHP framework.*

 [![Packagist](https://camo.githubusercontent.com/fdf9374da9dccb242a2bbedc03817f3bb4221fac80069b4d63f5cb8616473b17/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6d696c70612f6167656e742e737667)](https://packagist.org/packages/milpa/agent) [![PHP](https://camo.githubusercontent.com/ca03f11ea27dac4dedc8ad56a7bdfc4a9ff5feb825055f9d2983616115076607/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d254532253839254135253230382e332d3737376262342e737667)](https://www.php.net/) [![License](https://camo.githubusercontent.com/798509b4df525f56802b56f8096862487f08023e3d7561c68656f8dab10d0d6e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4170616368652d2d322e302d626c75652e737667)](LICENSE)

Milpa Agent
===========

[](#milpa-agent)

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.

Install
-------

[](#install)

```
composer require milpa/agent
```

The shape
---------

[](#the-shape)

```
use 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();
```

Why event-sourced
-----------------

[](#why-event-sourced)

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.

What a session carries
----------------------

[](#what-a-session-carries)

`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 forgetsThe policy
----------

[](#the-policy)

`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 | RequireSignature
```

**The 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.

Deadlines: a question does not wait forever
-------------------------------------------

[](#deadlines-a-question-does-not-wait-forever)

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 window
```

**Expiry 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.

Who answered
------------

[](#who-answered)

`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: false
```

The 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.

Compaction
----------

[](#compaction)

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 null
```

Two 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.

Autonomy modes
--------------

[](#autonomy-modes)

`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.

License
-------

[](#license)

Apache-2.0 © Rodrigo Vicente - TeamX Agency

---

Milpa is designed, built, and maintained by **[Rodrigo Vicente - TeamX Agency](https://teamx.agency/?utm_source=github&utm_medium=readme&utm_campaign=milpa&utm_content=agent)**.

###  Health Score

42

—

FairBetter than 88% of packages

Maintenance100

Actively maintained with recent releases

Popularity12

Limited adoption so far

Community8

Small or concentrated contributor base

Maturity41

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 64.3% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

4

Last Release

0d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/1993784?v=4)[rodrigomx](/maintainers/rodrigomx)[@rodrigomx](https://github.com/rodrigomx)

---

Top Contributors

[![rodrigoteamx](https://avatars.githubusercontent.com/u/269849276?v=4)](https://github.com/rodrigoteamx "rodrigoteamx (9 commits)")[![github-actions[bot]](https://avatars.githubusercontent.com/in/15368?v=4)](https://github.com/github-actions[bot] "github-actions[bot] (5 commits)")

---

Tags

phpframeworkaiAgentsessionevent sourcingcoding-agentmilpa

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/milpa-agent/health.svg)

```
[![Health](https://phpackages.com/badges/milpa-agent/health.svg)](https://phpackages.com/packages/milpa-agent)
```

###  Alternatives

[laravel/pao

Agent-optimized output for PHP testing tools

6736.9M240](/packages/laravel-pao)[laravel/agent-detector

Detect if code is running in an AI agent or automated development environment

797.1M26](/packages/laravel-agent-detector)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
