rawphp / laravel-capabilities-ai
Conversation / turn / proposal runtime for the Laravel Capabilities bus — pluggable LlmClient, progress store, bus-only tools
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- rawphp/laravel-capabilities: *
Requires (Dev)
- pestphp/pest: ^3.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Status: 0.x pre-stable — not Packagist-published.
Install: package VCS or monorepo path.
Conversation / turn / proposal runtime for the Laravel Capabilities bus.
Monorepo path: packages/laravel-capabilities-ai/ in laravel-capabilities-monorepo.
Scope (this package)
| Is | Optional turn / proposal runtime: queue a turn, claim it, loop LLM → tools, stream progress (array/Redis); tool side effects only via CapabilityBus::invoke; host seams for conversation context and tool catalog; thin LlmClient (fake + Anthropic) for turns and host completions that must not embed domain rules |
| Multimodal | User message content on ConversationContextProvider / LlmClient may be a string or a list of provider content blocks (text + base64 image for Anthropic vision). Hosts hydrate attachment bytes into context — this package does not store, claim, or fetch chat attachment files. End-to-end photo coach flows still need host upload + context hydration. |
| Is not | The capability bus / registry; chat channel bots (use messaging); product CLI; a general app-wide LLM SDK replacing laravel/ai; domain run(); generative UI or agent-native OS |
Requires rawphp/laravel-capabilities. Consumers install this package repo, not the monorepo.
Install (path package)
# monorepo root already path-wires this package
composer update rawphp/laravel-capabilities-ai
composer test:ai
Host app: require rawphp/laravel-capabilities-ai and register Rawphp\CapabilitiesAi\CapabilitiesAiServiceProvider (auto-discovery via extra.laravel.providers).
Config
Publish:
php artisan vendor:publish --tag=capabilities-ai-config php artisan vendor:publish --tag=capabilities-ai-migrations
Key defaults (config/capabilities-ai.php):
| Key | Default |
|---|---|
table_prefix |
capabilities_ai_ |
progress.driver |
array (or redis) — prod: redis; array outside testing throws unless CAPABILITIES_AI_ALLOW_UNSAFE=1 |
llm.driver |
fake (set CAPABILITIES_AI_LLM_DRIVER=anthropic or bind LlmClient for production) — fake outside testing throws unless CAPABILITIES_AI_ALLOW_UNSAFE=1 |
llm.anthropic.model |
claude-sonnet-4-6 (CAPABILITIES_AI_ANTHROPIC_MODEL) |
llm.anthropic.max_tokens |
64000 (CAPABILITIES_AI_ANTHROPIC_MAX_TOKENS) |
user_model |
null → falls back to auth.providers.users.model (CAPABILITIES_AI_USER_MODEL) |
claim_ttl |
120 (seconds; worker heartbeat / job timeout window) |
queue.connection |
null (CAPABILITIES_AI_QUEUE_CONNECTION) — applied to default RunTurnJob dispatch when set |
queue.name |
null (CAPABILITIES_AI_QUEUE_NAME) — applied to default dispatch; also marks AI-chat for core capabilities:integration-health when non-empty |
proposals.enabled |
true Phase-1 BC (CAPABILITIES_AI_PROPOSALS_ENABLED) — greenfield: set false |
reaper.stale_queued_minutes |
30 (CAPABILITIES_AI_REAPER_STALE_QUEUED) |
reaper.stale_running_grace_seconds |
60 (CAPABILITIES_AI_REAPER_RUNNING_GRACE) |
allow_unsafe |
false (CAPABILITIES_AI_ALLOW_UNSAFE) — local demos only |
max_tool_rounds |
8 |
routes.enabled |
false |
Progress events live in array/Redis — not MySQL product tables.
Bus principal (tool + accept invokes): TurnRunner and ProposalService resolve the conversation’s Laravel user via user_model / auth provider and pass caller=job plus that user as actor on CapabilityBus::invoke. Missing/unresolvable conversation.user_id fails closed (no silent default user). When CapabilityBus is bound, provider boot also fails closed if the user model is unset, missing, or has no query() (class check only, no DB). README “bare” tool invokes means no idempotency_key — not “no invoke options.”
Host integration (D-024 seams)
Happy-path AI-chat hosts configure — they do not rebind package runtime for queue or progress:
| Seam | Do | Do not |
|---|---|---|
| Queue | Set CAPABILITIES_AI_QUEUE_NAME / CAPABILITIES_AI_QUEUE_CONNECTION |
Full ConversationService rebind only to pick a queue |
| Progress side-effects | app()->extend(ProgressStore::class, …) in boot() after package bind |
singleton(ProgressStore::class, …) replacing redis/array |
| Idempotency readiness | Leave SP default StoreBoundIdempotencyReadiness (live core store ping; fail closed when unbound) |
Bind AlwaysReadyIdempotency in production (tests-only) |
| Proposals | CAPABILITIES_AI_PROPOSALS_ENABLED=false on greenfield |
Assume routes-only gate — flag also skips TurnRunner fence extract + history proposals |
| Stale turns | Schedule php artisan capabilities-ai:reap-stale-turns |
Host reapers on wrong tables / dual chat stores without a kill date |
| Product HTTP UX | Host routes → bus / AI services | Package route surgery or hijacking package chat HTTP for product UX |
| Diagnostics | Core php artisan capabilities:integration-health |
Confuse with HTTP GET …/capabilities/health |
Full greenfield checklist, kill-list template, and extend snippet: docs/user-guide.md.
Host seams
Bind before running turns:
Rawphp\CapabilitiesAi\Contracts\ConversationContextProvider— messages for the modelRawphp\CapabilitiesAi\Contracts\ToolCatalog— tools the model may call (names = capability names)Rawphp\Capabilities\Contracts\CapabilityBus— already provided by core
use Rawphp\CapabilitiesAi\Contracts\LlmClient; use Rawphp\CapabilitiesAi\Support\FakeLlmClient; use Rawphp\CapabilitiesAi\Support\AnthropicLlmClient; // Testing default $app->bind(LlmClient::class, fn () => new FakeLlmClient); // Production $app->bind(LlmClient::class, fn () => new AnthropicLlmClient( apiKey: config('capabilities-ai.llm.anthropic.api_key'), model: config('capabilities-ai.llm.anthropic.model'), ));
Custom LlmClient: implement supportsToolRounds(). Prefer use LlmClientDefaults (returns false) and override to true only if the client accepts tool-result messages on the next complete() (OpenAI-style role=tool or Anthropic tool_result blocks). Lying opens a bus-then-crash path. (PHP interfaces still cannot ship method bodies on supported PHP; the trait is the fail-closed default for hosts.) Host upgrade callouts: user guide · CHANGELOG Breaking.
MVS product default: multi-round tools are off until a client opts in. AnthropicLlmClient and FakeLlmClient opt in (supportsToolRounds() === true); hosts using LlmClientDefaults stay fail-closed until they override. Empty tool defs + refuse-before-bus is defense-in-depth for non-tool-round clients, not a second product surface.
Proposals (single accept/reject model): Gated by proposals.enabled (CAPABILITIES_AI_PROPOSALS_ENABLED). When false: accept/reject routes are not registered, TurnRunner skips fence → proposal extract, and history omits/empties proposals. When true: Accept returns typed AcceptOutcome for every known status (rejected/expired → refuse); HTTP maps outcomes + 404 when missing. Reject uses CAS + RuntimeException → 409 for non-pending. Greenfield: set false until you need proposals. Host upgrade callouts: user guide · CHANGELOG Breaking.
- Accept: atomic CAS
pending → accepting, thentarget_capabilitymust be in the hostToolCataloglist for the proposal's turn (D-008; checked on every execute, no catalog bound → refuse) or the proposal fails with 403capability_not_in_profileand no invoke; then bus invoke withidempotency_key=proposal:{ulid}(D-005). LiveStoreBoundIdempotencyReadinessprobe of coreIdempotencyStore(fail closed when unbound) — not a constructor stamp;AlwaysReadyIdempotencyis unit-tests only. BranchisApprovalRequired()thenisHardRefuse()thenisRetryable(); approval/retry leave statusacceptingfor host re-drive. Hard non-retryable →failed+last_error. Success → atomicaccepting → accepted, clearlast_error. Returns typedAcceptOutcome(accepted|approval_required|retryable|failed|refuse). - Reject: atomic CAS
pending → rejectedonly; already-rejected is idempotent; accepting/accepted/failed/expired refuse (HTTP 409). - Recovery: stuck
acceptingis intentional (approval / retry / crash mid-accept). Package does not TTL-expire or reclaim; host re-drives accept under the same D-005 key (proposal:{ulid}). Hosts must wire coreIdempotencyStore(not an AI-package store) so the bus actually dedupes; readiness not ready → 503 without invoke. Conversation/tool bus invokes stay without anidempotency_key— only accept sets the proposal key. Both tool and accept invokes still carry the job+user principal (above). - Stale turns: schedule
php artisan capabilities-ai:reap-stale-turns(host owns the schedule; package does not auto-schedule). Thresholds:reaper.stale_queued_minutes,reaper.stale_running_grace_seconds(running age uses max(claim_ttl, grace)).
Env: ANTHROPIC_API_KEY (never required in CI — tests use Http::fake / FakeLlmClient).
Flow
- Cheap create —
ConversationService::createUserMessageinserts message + queued turn, dispatchesRunTurnJob(no LLM). - Claim + run —
TurnClaimatomic update;TurnRunnerloops LLM → tools viaCapabilityBus::invokeonly. - Proposals —
ProposalService::accept/rejectas above (bus-only side effects on accept).
ProgressStore
Package binds ProgressStore in register() when unbound (array or redis from config). Hosts that need side-effects (TTS, metrics, …) must wrap with extend in boot() so the package store is the $inner:
// AppServiceProvider::boot — after CapabilitiesAiServiceProvider has registered use Rawphp\CapabilitiesAi\Contracts\ProgressStore; $this->app->extend(ProgressStore::class, function (ProgressStore $inner, $app) { return new TtsDispatchingProgressStore($inner, $app->make(TtsService::class)); });
Forbidden: host singleton(ProgressStore::class, …) that replaces redis/array wiring (rebind, not extend).
use Rawphp\CapabilitiesAi\Support\ArrayProgressStore; use Rawphp\CapabilitiesAi\Support\RedisProgressStore; $store = new ArrayProgressStore; $store->append($turnUlid, ['kind' => 'status', 'data' => ['status' => 'running']]); $events = $store->since($turnUlid, $cursor);
Kinds: status | token | tool | error | terminal.
License
MIT
Non-chat / MVS host jobs
Hosts may resolve LlmClient without a Conversation (e.g. Macro Validation Suite jobs):
/** @var \Rawphp\CapabilitiesAi\Contracts\LlmClient $llm */ $llm = app(\Rawphp\CapabilitiesAi\Contracts\LlmClient::class); $result = $llm->complete([ ['role' => 'user', 'content' => 'Summarize this payload…'], ]);
The LlmClient interface has no conversation-only dependency. Testing default is FakeLlmClient (no network).