agent()->withTools([ * new Plugin\Tools\PdoQuery(), * new Plugin\Tools\HttpGet(), * new Plugin\Tools\FileRead(), * ]); * echo $agent->run('How many active users do we have?'); * * Tool-call protocol uses a simple JSON sentinel — no model-specific * tool-calling APIs needed, so it works on every Ollama model. Models that * support native tool-calling can be plugged in via a subclass override * of {@link parseToolCall()}. */ class Agent { protected \stdClass $cfg; protected Chat $chat; /** @var Tool[] */ protected array $tools = []; /** @var array */ protected array $trace = []; public function __construct(\stdClass $cfg, Chat $chat) { $this->cfg = $cfg; $this->chat = $chat; } /** @param Tool[] $tools */ public function withTools(array $tools): self { $this->tools = $tools; return $this; } /** * Run the agent against a task. Returns the final answer text. */ public function run(string $task): string { $maxIter = (int) ($this->cfg->agent_max_iterations ?? 6); $this->trace = []; $system = $this->systemPrompt(); $this->chat->reset()->system($system)->user($task); for ($step = 1; $step <= $maxIter; $step++) { $reply = $this->chat->complete(); $call = $this->parseToolCall($reply); if ($call === null) { // Final answer — no tool call detected. return $this->stripFinalMarker($reply); } $tool = $this->findTool($call['tool']); if ($tool === null) { $obs = "ERROR: no tool named \"{$call['tool']}\". Available: " . implode(', ', array_map(fn($t) => $t->name(), $this->tools)); } else { try { $obs = (string) $tool->execute($call['args']); } catch (\Throwable $e) { $obs = 'ERROR: ' . $e->getMessage(); } } $this->trace[] = ['step' => $step, 'action' => json_encode($call), 'observation' => $obs]; // Feed the observation back into the conversation. $this->chat->user("Observation:\n" . $obs . "\n\nContinue."); } return $this->chat->complete(); // final attempt after max iterations } /** * @return array */ public function trace(): array { return $this->trace; } // ----------------------------------------------------------------------- // Internals // ----------------------------------------------------------------------- protected function systemPrompt(): string { $toolBlock = ''; foreach ($this->tools as $t) { $toolBlock .= $t->asPrompt() . "\n\n"; } return <<", "args": { ... }} ``` After you receive the observation, decide whether to call another tool or to produce a final answer. When done, write the final answer prefixed with "FINAL:" on its own paragraph. Do not call a tool and produce a final answer in the same turn. Available tools: $toolBlock Be concise. Prefer real tool data over guesses. If a tool errors twice, give a final answer that says you couldn't find out, and why. PROMPT; } /** * Look for a ```tool { ... }``` JSON block. Returns null if absent. * * @return array{tool:string,args:array}|null */ protected function parseToolCall(string $reply): ?array { if (preg_match('/```(?:tool|json)\s*\n(.*?)\n```/s', $reply, $m)) { $decoded = json_decode($m[1], true); if (is_array($decoded) && isset($decoded['tool'])) { return [ 'tool' => (string) $decoded['tool'], 'args' => is_array($decoded['args'] ?? null) ? $decoded['args'] : [], ]; } } // Fallback: explicit final marker if (preg_match('/^FINAL:\s*/m', $reply)) return null; // No tool call detected — treat as final. return null; } protected function stripFinalMarker(string $reply): string { return preg_replace('/^FINAL:\s*/m', '', $reply) ?? $reply; } protected function findTool(string $name): ?Tool { foreach ($this->tools as $t) { if ($t->name() === $name) return $t; } return null; } }