Agent plugin
A ReAct-style tool-using agent. Extend Tool to give it any PHP capability you can write.
The Agent plugin lets you give an LLM the ability to act — to call SQL queries, hit HTTP endpoints, read files, or do anything else you can express as a PHP method. It runs a ReAct-style loop: think → tool-call → observe → repeat → answer.
Five lines, one agent
Section titled “Five lines, one agent”use Nibiru\Module\Ai\Ai;use Nibiru\Module\Ai\Plugin\Tools\PdoQuery;
$ai = new Ai();echo $ai->agent() ->withTools([new PdoQuery()]) ->run('How many active users do we have?');// → "We have 1,247 active users." (after the agent ran SELECT count(*)…)How it works
Section titled “How it works”user task ↓LLM gets system prompt with tool definitions ↓LLM emits ```tool {"tool":"pdo_query","args":{"sql":"SELECT…"}}``` ↓Agent runs the tool, captures result ↓LLM gets observation, decides: more tools or final answer? ↓"FINAL: 1,247 active users."The protocol uses a fenced-JSON sentinel — \“tool {…}```— that any model can produce. No native tool-calling API required, so it works on every Ollama model out of the box. (Models that support native tool-calling can be plugged in via a subclass that overridesparseToolCall()`.)
Built-in tools
Section titled “Built-in tools”Nibiru ships three:
| Tool | What it does |
|---|---|
Tools\PdoQuery | Single read-only SELECT against the app DB. Blocks INSERT/UPDATE/DELETE/DROP/TRUNCATE/ALTER. Returns up to 50 rows as JSON. |
Tools\HttpGet | GET an HTTP/HTTPS URL with optional headers. Returns body, truncated to 8 KB. |
Tools\FileRead | Read a project file by relative path. Blocks .. traversal. Returns up to 8 KB. |
use Nibiru\Module\Ai\Plugin\Tools;
$agent = $ai->agent()->withTools([ new Tools\PdoQuery(), new Tools\HttpGet(), new Tools\FileRead(),]);
// Multi-step taskecho $agent->run( 'Read application/controller/loginController.php and tell me ' . 'whether it implements rate limiting.');The agent will call file_read with the path, observe the source, and answer based on what it actually saw — not on what it imagines.
Writing a custom tool
Section titled “Writing a custom tool”Extend Tool:
namespace App\AiTools;
use Nibiru\Module\Ai\Plugin\Tool;
class StripeRefund extends Tool{ public function name(): string { return 'stripe_refund'; }
public function description(): string { return 'Issue a Stripe refund for a charge ID.'; }
public function schema(): array { return [ 'charge_id' => [ 'type' => 'string', 'description' => 'A Stripe charge ID, e.g. ch_3K…', 'required' => true, ], 'amount_cents' => [ 'type' => 'integer', 'description' => 'Amount to refund in cents. Omit for full refund.', 'required' => false, ], ]; }
public function execute(array $args): mixed { $stripe = new \Stripe\StripeClient(getenv('STRIPE_SECRET_KEY')); $refund = $stripe->refunds->create(array_filter([ 'charge' => $args['charge_id'], 'amount' => $args['amount_cents'] ?? null, ])); return json_encode([ 'refund_id' => $refund->id, 'status' => $refund->status, 'amount' => $refund->amount, ]); }}Then plug it in:
$ai->agent() ->withTools([new \App\AiTools\StripeRefund(), new Tools\PdoQuery()]) ->run('Refund order #4421 — they were charged twice.');The agent will use pdo_query to find the charge, then call stripe_refund with that charge ID.
Looking at the trace
Section titled “Looking at the trace”$agent = $ai->agent()->withTools([new Tools\PdoQuery()]);$answer = $agent->run('How many products in the gold-plating category?');
foreach ($agent->trace() as $step) { echo "Step {$step['step']}: action={$step['action']}\n obs={$step['observation']}\n";}Useful for debugging, audit trails, or building a “show your work” UI.
Safety
Section titled “Safety”PdoQueryblocks writes. If you want write access, write a more privileged subclass with an audit trail. Don’t lift the SELECT-only restriction in the built-in tool.HttpGetallows any URL by default. Lock down via an allowlist in[AI] http_allowed_hosts[](planned), or write aRestrictedHttpGetsubclass that filters URLs.FileReadblocks... It’s confined to the application root.- Max iterations.
agent.max_iterations = 6in the INI prevents runaway loops. Raise carefully. - Tool timeout.
agent.tool_timeout = 30(seconds). A tool that hangs won’t hold the request forever.
Common pitfalls
Section titled “Common pitfalls”- Forgetting
withTools(). Without tools, the agent is just a regularChat. - Letting the agent see secrets. Never put API keys, raw passwords, or PII into a tool’s response — the model receives the full string.
- Long tool outputs. Each observation is appended to the conversation. A tool that dumps 50 KB will exhaust context fast. The built-in tools cap at 8 KB; do the same in your custom tools.
- No tool-call in reply = final answer. If the model produces a final answer that looks like a tool call but doesn’t validate, the agent treats it as final. Be explicit in the prompt: “Output a tool call OR a final answer, never both.”