PHP interfaces
About 1612 wordsAbout 5 min
All interfaces live in app/. The authoritative source is the source code — this doc describes intent and usage rules.
PHP Attributes (Tool Metadata)
app/Tools/Attributes/ — read via PHP Reflection by the Orchestrator and UI.
| Attribute | Target | Purpose |
|---|---|---|
#[Tool(name, description, displayName, category)] | class | LLM-facing snake_case name (must match /^[a-z][a-z0-9_]*$/), LLM description, optional human display name, category for UI grouping (default "general"). Required on every tool. |
#[ToolOperation(name, description, enabledByDefault, requiresApprovalByDefault, discriminatorKey)] | class (repeatable) | Per-operation enabled/approval flag. discriminatorKey (default "action") is the argument field the LLM sends to pick the operation. Replaces the class-level Input/OutputTool split. |
#[ToolParameter(name, type, description, required, default, enum, minimum, maximum, format, items)] | class (repeatable) | Read by ToolParameterSchemaBuilder to auto-generate the JSON Schema parameters block. The default implementation lives in the HasParameterSchema trait (composed by AbstractTool); tools may override getParametersSchema() for custom shapes. |
#[ToolSetting(key, label, type, description, default, required, options, validation, exposeToLlm)] | class (repeatable) | UI-configurable setting. type is "text"|"password"|"select"|"toggle". exposeToLlm: true includes the effective value in the LLM tool description. Global values live in tool_configurations.settings; per-user overrides in tool_user_settings.settings; per-agent overrides in agent_tool_overrides.settings. Merge order (later layers win, schema defaults fill unset keys): schema defaults → tool_configurations → tool_user_settings → agent_tool_overrides. See ToolConfigService::getEffectiveSettings() (app/Services/ToolConfigService.php:231-267). Never sent to LLM unless exposeToLlm is set. |
Since spora-core#165, required accepts bool|list<string> — list form is per-op binding (required: ['format']).
ToolInterface (app/Tools/ToolInterface.php)
Unified tool interface — replaces the previous InputToolInterface / OutputToolInterface split. Per-operation enabled/approval state is read from #[ToolOperation]. Tools without #[ToolOperation] declarations are treated as single-operation tools with class-level defaults.
execute(array $arguments, int $agentId, ?int $taskId = null, ?PrincipalContext $context = null): ToolResult— four parameters; MUST NOT throw, encode errors inToolResult. The only call site isOrchestrator::safeExecute()(app/Agents/Orchestrator.php:544, see Architecture → Orchestrator Loop), which fills every argument:$arguments— key-value pairs matching the#[ToolParameter]names.$agentId— the agent issuing the call.$taskId— the current tick’s task id, so chat-level tools (sub_agent,summarize,archive) can reference the sourceTaskwithout re-querying by user id.$context— the ownership bundle (Spora\Services\PrincipalContext, built byPrincipalResolver::resolveForToolExecute()atapp/Agents/Orchestrator.php:533from the calling agent’s row plus the latest task). ReadownerUserIdfor “who pays” — credential encryption, settings scope, audit attribution — andrunnerUserIdfor “who clicked” — memory-write attribution, Mercure publish targets, thetasks.user_idcolumn. Guard tenant-scoped code with$context?->isResolvable()rather than comparing against0; a missing agent row and a danglingprincipal_idproduce two different unresolvable sentinels.
The legacy
?int $userIdparameter is gone (spora-core PR #288). It was read from the calling agent’s row, so it was always$context->ownerUserId, andToolConfigPrincipalCascadeignored it outright whenever$contextwas present — which the orchestrator always supplies. Read the context. Dropping the parameter breaks every implementation that still declares it fatally, at class-load, so the interface change and the plugin updates that remove those declarations must ship in the same release: four parameters against a five-parameter interface is the same fatal, so a plugin cannot adopt the change early either. Both directions fail loudly, by design.
The other two methods:
describeAction(array $arguments): string— human-readable, markdown-safe description for the approval UI.getParametersSchema(): array— returns the JSON Schemaparametersobject (type: "object",properties,required).
The Orchestrator pauses the loop on tool turns that resolve to requiresApprovalByDefault: true (creating PENDING_APPROVAL tool-call rows and setting the task to PENDING_APPROVAL); TaskController calls Orchestrator::resume() after human approval. There is no separate ApprovalResumeHandler — Orchestrator::resume() performs the validation, execution, history append, status reset, and conditional tick() itself.
OrchestratorInterface (app/Agents/OrchestratorInterface.php)
start(agentId, userPrompt, maxSteps = 10, parentTaskId = null, runId = null): Task— creates aTaskwithstatus: QUEUED, appendsuserhistory. The worker (server daemon or browserSharedWorker) drives the resulting task — see Deployment modes.tick(taskId, ?OrchestratorConfig $config = null): void— one loop iteration: short claim transaction (lock, max-step check, status read) → LLM call (outside transaction) → write results. Recurses viatick()when a tool turn completes without requiring approval. When$config->leaseOwner !== null, the orchestrator extends the lease on every step boundary (used by both/tickin client mode and the housekeeping synchronous tick).resume(taskId, decisions): void—decisionsis alist<array{provider_call_id: string, decision: 'approve'|'reject', arguments?: array<string, mixed>, reason?: string}>(PR #173). In alockForUpdate()transaction: load task +AgentState, split the batch viaAgentDecisionProcessor::splitDecisions(). Approved entries are handed toApprovedBatchExecutor(partial-approval semantics preserved — undecided rows stayPENDING_APPROVAL); rejected entries are stampedREJECTEDwithrejected_at/rejected_by/reject_reasonand arole:'tool'history row is appended carryingtoolCallId+toolNameso the LLM sees the rejection in its next round-trip. The task transitions back toQUEUEDonly when the batch leaves noPENDING_APPROVALrows. The next worker pickup (server daemon’stask:runor browser’s/tick) drives the nexttick()so the LLM round-trip does not hold the lock open.reject(taskId, reason): void— task-level bulk reject. In alockForUpdate()transaction: load task, assertPENDING_APPROVAL, clearpending_state. Then outside the transaction: markPENDING_APPROVALtool callsREJECTED, appendAction rejected by user: {reason}history rows, set task status toQUEUED. The bulk path does not writerejected_at/rejected_by/reject_reason— those columns are reserved for per-call rejection viaresume().continue(taskId, newPrompt, additionalSteps = null): Task— append a new user message, resetstep_count, optionally overridemax_steps, re-enter the loop. Accepted sources:COMPLETED,FAILED,ABORTED, andRUNNING(added in spora-core PR #207 — aRUNNINGsource first flips the task toABORTEDwith anabort_markerhistory row, then re-prompts with the new message; the abort happens inside the same transaction so the worker that owned the previous loop is forced to bail). TheABORTEDsource clearsdata.aborted_aton resume (added in spora-core PR #266: also clearsdata.max_steps_reachedso a subsequent manual abort does not inherit the step-cap flag and mislabel itself in the chat banner).abort(taskId): Task— user-initiated quiescent halt (added in spora-core PR #207). In alockForUpdate()transaction: load task, allow onlyRUNNING/AWAITING_SUB_AGENTS/ABORTED(idempotent — already-ABORTEDreturns the row without rewriting), stampABORTEDviaTaskStatusWriter::abortTransitionwhich writes thedata.aborted_attimestamp. ThrowsInvalidTaskTransitionExceptionfor any other source status; the controller surfaces this as HTTP 409INVALID_STATE(not 400) — the source exists but is not in a state that allows the operation. After commit the implementation publishes via Mercure on the user-scoped task topic.appendHistory(taskId, role, content, context?)— write a single row intotask_history. Used by extracted services (e.g.SubAgentService) that pre-existed the interface but need to write back into the orchestrator’s history stream without a direct coupling.contextis aHistoryMessageContext(carriestoolCallId,toolName,toolCallPayload,inputTokens,outputTokens,contentBlocks,usage); defaults to an empty context. Promoted from the implementation to the interface in spora-core PR #196.
The transaction boundary in resume() and reject() is intentional: the LLM round-trip never holds a lockForUpdate() open during network I/O. The legacy WorkerMode::Sync enum case was removed in 0.19.0 (the WorkerMode enum is single-case Worker); the runtime mode that gates which worker drains the queue is the separate WorkerRuntimeMode enum (server / client).
LLMDriverConfigInterface (app/Drivers/LLMDriverConfigInterface.php)
Drivers are resolved per-request by DriverFactory::makeFromAgent() (app/Drivers/DriverFactory.php:51-61), which delegates three-tier resolution to LLMConfigPreferences::getEffectiveConfigForAgent() (app/Services/LLMConfigPreferences.php:81-97): the agent’s own llm_driver_config_id FK → the principal’s preferred config (principal_preferences.preferred_llm_config_id, looked up through the agent’s principal_id at app/Services/LLMConfigPreferences.php:90) → the global default (is_global = true AND is_default = true).
static getName(): string— snake_case identifier, e.g."openai_compatible","anthropic_compatible".static getDisplayName(): string— human-readable, e.g."OpenAI Compatible".static getDefaultTools(): list<class-string>— default tool list for this driver.
Settings are stored encrypted in LLMDriverConfiguration.settings (JSON blob) and discovered per-driver via #[ToolSetting] attribute reflection — no hardcoded field lists. The interface itself does not expose a getSettingsSchema() method; discovery happens in LLMConfigSchemaInspector::buildSchemaFromClass() (app/Services/LLMConfigSchemaInspector.php:55-70) via ToolSettingSchema::collect(), not in the controller or ToolConfigService (those handle the tool settings path). Implementations must be registered in the container under llm_driver_classes.
PluginInterface (app/Plugins/PluginInterface.php)
PluginInterface is a pure marker:
interface PluginInterface extends SporaExtensionInterface {}It declares no methods of its own. The ten data hooks are declared on the parent Spora\Extensions\SporaExtensionInterface and nowhere else. The three PSR-14 lifecycle events are not on the interface — they are separate final classes in Spora\Events (ContainerBuildingEvent, RoutesRegisteringEvent, BootingEvent) that an extension opts into by also implementing Symfony\Component\EventDispatcher\EventSubscriberInterface and returning the event → method map from getSubscribedEvents(). Either way a project App (Spora\Extensions\AppInterface, also a bare marker) and a Composer-distributed plugin share one data contract and one wiring. Extend Spora\Plugins\AbstractPlugin for empty defaults on every hook, or Spora\Extensions\AbstractExtension for the same defaults on either side.
For the authoritative list of hooks, returns, and purposes, see Plugin system → Hooks. For the behavioural half — ContainerBuildingEvent, RoutesRegisteringEvent, and BootingEvent — see Plugin system → Lifecycle Events and App extensions → Lifecycle events.
Note: six methods that pre-1.0 docs listed on this interface no longer exist:
autoload(),drivers(), andrecipePaths()had no callers (PSR-4 data moved tocomposer.json/plugin.json; LLM providers are configured, not contributed), andregister(),routes(), andboot()became PSR-14 events that extensions opt into by implementingSymfony\Component\EventDispatcher\EventSubscriberInterface.