Monorepo:
| Package | Role | PHP |
|---|---|---|
packages/agentic — gplanchat/agentic |
Domain/: the model (tool guard, questions, watches, context budget, delegation), framework-free. Application/: ports and use cases (Conversations, AgentTool, help). Infrastructure/: the durable workflow and its Symfony AI 0.13 adapters (pinned). Depends on gplanchat/durable. |
≥ 8.2 |
packages/agentic-bundle — gplanchat/agentic-bundle |
Symfony integration: the agentic TUI application (help, chat) and its web version. Depends on gplanchat/durable-bundle and symfony/tui. |
≥ 8.4.1 |
| root | Dev application installing both by path. |
≥ 8.4.1 |
The decisions the code cannot state on its own — why, and what was turned down — are in
docs/decisions/. Start with
ADR-001: value objects over arrays, enums over
magic strings, and where the journal boundary puts the limit on both.
composer install
bin/agentic # help in the TUI (q, Esc or Ctrl+C to quit)
bin/agentic chat # talk to the agent (Shift+Tab mode, ↑↓ history, wheel scrolls, Ctrl+X closes, Ctrl+C quits)
bin/agentic chat <conversation> # resume a conversation (its id is printed when you quit)
php8.4 -S localhost:8000 -t public # the web version: http://localhost:8000/agentic/Tests, package by package (PHPUnit 11 for agentic, which must stay installable on PHP 8.2):
(cd packages/agentic && composer install && php8.2 vendor/bin/phpunit)
(cd packages/agentic-bundle && composer install && php8.4 vendor/bin/phpunit)Each package splits its suite along the test pyramid (static, unit, functional,
integration; --testsuite <layer>); tests/TestPyramidTest.php fails when a test sits in no
layer or in two. No e2e yet.
The static layers run PHPUnit's static suite then PHPStan (level 8, phpstan.dist.neon per
package, a baseline for the errors the code already had: fixing one means deleting its entry).
New code meets the level — it does not go to the baseline.
The mutation layers run Infection (tools/infection, a tool project of its own: it needs PHP ≥ 8.3,
so it cannot be a dependency of packages/agentic, which stays installable on 8.2) over the lines
changed since HEAD, new files included. A surviving or uncovered mutant is RED, and the verdict asks
for a sharper test — never for other code. Two things follow from where Infection lives: it runs the
tests it mutates under PHP 8.4, so a green component-mutation says nothing about the 8.2 floor that
component-unit and component-functional check; and a worktree borrows tools/*/vendor read-only
through sandbox.shared, like any other installed dependency.
A conversation is an execution of the DurableAgentWorkflow workflow; every message, approval,
answer or alert is a signal. The TUI acts as the worker: it drains Durable's Messenger transports on
every refresh, with no messenger:consume alongside.
-
Without
MISTRAL_API_KEY, a scripted client answers, with no network: "What is the weather in Paris?" (read-only tool), "Send a mail" (approval), "Ask me a question", "Watch the delivery" (watch), "Delegate…" (sub-agent). A trigger is matched as a whole word. -
With
MISTRAL_API_KEY(in.env.local, ignored by git, or in the environment), Mistral answers; its replies are formatted (Markdown). The model call happens inside the TUI process, but through an Amp HTTP client on the same event loop: the screen stays alive while it waits — the banana dances, the counter runs, the thread can be scrolled. -
A tool that fails three times in a row is handed back to the model as its result ("Tool … failed"); a failing model call ends the conversation, and the header says why.
-
Chat commands, typed instead of a message (Tab completes the name):
/help,/mode [plan|standard|edition|auto],/model [name](from the next message on),/tools(and what the guard makes of each in the current mode),/clear(new conversation),/rewind [n°](go back before one of your messages, which returns to the input),/compact(restart from a summary),/resume [id](resume a past conversation)./rewind,/compactand resuming a finished conversation open a fresh conversation from the thread: the journal of the old one is never rewritten. -
The wheel and PgUp/PgDn scroll the thread inside the chat: the terminal switches to mouse mode for the duration. To select text with the mouse, hold Shift (most terminals).
-
↑/↓ recall the messages and commands already sent, like a shell.
-
Modes (Shift+Tab rotates,
/modesets):planreads only and refuses the rest,standardreads and holds writes for approval,editionwrites and holds external effects,autolets everything through.planis not a stricterstandard:standardsuspends the conversation on an approval card,plananswers the model at once and tells it to say what it would do instead — so a planning agent keeps planning rather than waiting for a decision nobody meant to take. Leaving plan mode is the human's move,/mode edition.planis the floor of the order, so a delegate under a planning caller plans too, whatever its profile's ceiling says. -
Token budget (
token_budgetinconfig/packages/agentic.php): what one run may spend before it stops taking turns. 0 by default, which caps nothing — the spend is counted either way, and the header shows it, because counting is the part that cannot be done afterwards. The ceiling is a policy an application sets knowing its own traffic. Two things it does not do: the check runs before a turn, not during one, so a run overshoots by at most one turn (bounded bymaxToolCallsmodel calls); and a delegate is a child workflow with its own journal, so its spend is not counted against its caller's — a run has a budget, a conversation with sub-agents does not yet. Offline, the scripted client prices its replies at four characters per token so the counter behaves as it does with a provider. A delegation is counted: a child hands its total back in its return value — the only channel from a child workflow to its parent — and since each level adds what its own delegates reported before reporting in turn, one addition per level carries the whole tree. The same number reaches the display, so the header and the ceiling cannot drift. -
Depth of delegation (
max_delegation_depth, 2 by default): a delegate keeps thedelegatetool, so without a bound an anonymous delegation could delegate for ever, each level costing a model call. At the deepest level allowed the tool is not offered at all, rather than offered and refused: a tool the model cannot see is one it does not spend a turn reaching for.0forbids delegating outright. -
Decision hooks (
tool_rulesinconfig/packages/agentic.php): for a tool (anfnmatchpattern) and, where needed, conditions on its arguments,allow,askordeny, before the mode. Deny wins over ask, which wins over allow;/toolsshows the rules. -
run_commandin a sandbox (sandboxinconfig/packages/agentic.php): bubblewrap mounts the project writable,/usrand/etcread-only, nothing else of the disk; no network, a cleared environment,.env.local,.env.*.localandvar/hidden,.gitand.clauderead-only. Limit: whatever the agent writes into the project (composer.json,vendor/bin/*, a test bootstrap…) will run on the host when you launch it — review before you do. The screen freezes for the duration of a command (120 s at most). No shell: the line is split then executed as is (;,|,$(…)stay literal). Outsideauto, every command asks for approval; inauto, only those ofsandbox.auto_allowpass (fnmatchpatterns over the whole line). If bwrap is missing or refused, the chat says so when it opens (sudo apt install bubblewrap). Rules also acceptmodesandunless:['tool' => 'run_command', 'modes' => ['edition'], 'when' => ['command' => 'vendor/bin/phpunit*'], 'decision' => 'allow']. -
A worktree per conversation. A conversation that runs a command works in
.worktrees/agentic-<id>, on branchagentic/agentic-<id>, cut from HEAD — the project's uncommitted changes are not in it. The agent cannot commit from inside the sandbox (the project's.gitis read-only there), so all of its work sits uncommitted in the worktree andgit diffalone hides the files it created. Review withgit -C .worktrees/agentic-<id> status, thengit -C .worktrees/agentic-<id> add -A && git -C .worktrees/agentic-<id> diff --cached; take the work by committing in the worktree. Discard it withgit worktree remove .worktrees/agentic-<id> && git branch -D agentic/agentic-<id>. Do not run anything on the host inside a worktree —bin/console,bin/agentic,composer, a test suite, an agent session — before you have reviewed it: ignored files the agent created are invisible togit status. The sandbox pre-creates and masksvar/,.claude/and.env.localin each worktree, but not every.env.*.local. Nothing is cleaned up automatically, and that is deliberate — a worktree holds the agent's work until someone reviews it, and/rewind,/compactand/resumeopen new conversations that keep the same worktree, so tying removal to a conversation ending would destroy live work. A cleanup command may come later. -
run_checks(layer, filter?)runs one layer of the project checks — whateversandbox.checksnames: static, unit, functional, integration, e2e — in the same sandbox and workspace, then reads its JUnit report. It answersGREEN — <layer>orRED — <layer>with the counts and each failing test with itsfile:lineand message (ten at most), instead of kilobytes of raw output;ERROR — <layer>when no report came out, with the tail of the output, andREDtoo when the exit code fails although no test did. Its arguments are closed — a configured layer, a filter passed as a single value tofilter_option— so it needs no allowlist. Classedwrite: it asks instandard, passes ineditionandauto. One timeout per layer (300 s by default).testsper layer says where its tests live;reviewper layer names the layers to run once it is green — the GREEN verdict spells them out, in order —, and an unknown name there is refused when the tool is built. With checks configured, the system prompt carries the pyramid and the TDD cycle (red → green → review), and each verdict ends with the next step. A layer that runs the bundle suite works from inside the sandbox, nested bwrap included. -
Sub-agents (
agentsinconfig/packages/agentic.php): named profiles thedelegatetool can hand a mission to, each with its description (which the model reads to choose), its instructions, its model, the tools it may use (fnmatchpatterns) and its ceiling./agentslists them,/agents <name>shows one in full, instructions included. A profile never grants authority: the sub-agent takes the strictest of its ceiling and of its caller's effective mode, so a profile declaredautostaysstandardin astandardconversation — naming a sub-agent must not be the way around the guard. A name nobody declares is refused and the model is told which exist. The profiles are frozen in the conversation's payload at start, like its tools. -
MCP servers (
mcp.serversinconfig/packages/agentic.php): their tools are offered to the agent asmcp__<server>__<tool>, over stdio (command,args,env,cwd) or HTTP (url,headers)./mcplists the servers, says which are reached and shows their tools. An MCP tool isexternalby default, so it asks for approval in every mode butauto: what a server says about its own tools —readOnlyHintand the other annotations — is its claim about itself, and the guard is what protects from a tool that lies. Give effects explicitly witheffects(a tool name pattern →read,writeorexternal), which wins over everything else, or accept the server's word withtrust_annotations: true. Discovery happens when a conversation starts and its schemas are frozen in the payload: a server that changes its tools afterwards does not change a running conversation, and a tool that disappeared comes back to the model as a failed result. A server that is down costs nothing — no tool, an error shown by/mcp, the conversation starts anyway. Tried against the official GitHub server, which offers 45 tools:'mcp' => ['servers' => ['github' => [ 'command' => 'docker', 'args' => ['run', '-i', '--rm', '-e', 'GITHUB_PERSONAL_ACCESS_TOKEN', 'ghcr.io/github/github-mcp-server', 'stdio'], 'env' => ['GITHUB_PERSONAL_ACCESS_TOKEN' => '%env(GITHUB_TOKEN)%', 'PATH' => '%env(PATH)%', 'HOME' => '%env(HOME)%'], 'effects' => ['get_*' => 'read', 'list_*' => 'read', 'search_*' => 'read'], ]]],
A server reached over
urlworks too — GitHub's remote endpoint answers the Streamable HTTP way, with atext/event-streamthat stays open, and the client reads its events as they come. One difference with stdio: an MCP call over HTTP freezes the screen for its duration, because the MCP transport waits inside a fiber of its own rather than on the event loop the TUI runs on. -
read_fileandedit_filework in the same sandbox and the same workspace asrun_command(the conversation's worktree).read_file(read, passes in every mode) prints numbered lines, 400 by default, withoffset/limit, and says how to read on; on a directory it lists the entries.edit_file(write: asks instandard, passes ineditionandauto) replaces an exactold_string, which must be unique unlessreplace_allis set; an emptyold_stringcreates a file — and its directories — that must not exist yet. Both run as a small PHP script inside bwrap, so they see exactly whatrun_commandsees. They refuse what resolves outside the workspace (.., absolute paths, symlinks), what is read-only (.git,.claude, the borrowedvendor/) and what is masked (.env.local,var/…), with a sentence rather than a silent success. Two things worth saying plainly: inauto,edit_fileplus an allowlistedvendor/bin/phpunitruns whatever the agent just wrote, without approval —auto_allowlimits which programs start, not what code they run, and the sandbox is the barrier; andread_filereads HEAD's version of the files, not your uncommitted edits, its first call being what cuts the worktree even instandard. -
AGENTS.mdat the project root (path configurable throughinstructions_file): appended to the system prompt when a conversation starts, truncated beyond 32 KiB. It carries the code convention of ADR-001 and nothing the prompt already says — the pyramid and the TDD cycle come fromrun_checks, and paying for them twice would cost every turn of every conversation. -
Journal on SQLite (
var/agentic.sqlite): conversations survive the TUI being closed and can be resumed. The Messenger transports stay in memory, the TUI being the only worker: a turn in flight when you quit is lost. A conversation belongs to whoever opened it, and a run that fails keeps its owner: Durable drops the execution's metadata row on failure — the start payload with it — so the projection recovers the owner from the journal, where every tool call carries it. A run that failed before calling a single tool leaves nothing to recover; it is refused as unknown owner, not as somebody else's, and the chat says so in its header rather than dying on it.
