The AI tool-execution runtime for the Milpa PHP framework, built on
milpa/core. It runs the loop every Milpa module declares:plugin → capability → tool → verification → event → result.#[Tool]-attributed methods become a registry pipeline — resolve, validate, authorize, execute, audit — with policy gates, rate limiting, channel-aware rendering, and human/agent verification as first-class seams.
milpa/tool-runtime is where milpa/core's agent-tool-readiness seam becomes a working
engine. Milpa\Interfaces\Tooling\ToolProviderInterface and ToolRegistryInterface are
contracts defined in core; this package is the concrete ToolRegistry that resolves,
validates, authorizes, executes, and audits every call, plus the #[Tool] attribute that
lets a plain PHP method declare itself as agent-callable. No Doctrine, no HTTP kernel, no
concrete policy storage — those live in your host application.
composer require milpa/tool-runtimeAttribute a method with #[Tool]; parameters describe themselves with #[Param]:
use Milpa\ToolRuntime\Attributes\Param;
use Milpa\ToolRuntime\Attributes\Tool;
use Milpa\ToolRuntime\ToolResult;
final class NoteTools
{
#[Tool('list_notes', 'List saved notes', scopes: ['notes:read'])]
public function listNotes(
#[Param('Page number', clamp: [1, 1000])] int $page = 1
): ToolResult {
return ToolResult::success(['notes' => [], 'page' => $page]);
}
}ToolScanner reflects the class for #[Tool] methods and registers them; ToolRegistry
runs the full pipeline on every call:
use Milpa\ToolRuntime\Contracts\ToolContext;
use Milpa\ToolRuntime\ToolRegistry;
use Milpa\ToolRuntime\ToolScanner;
use Psr\Log\NullLogger;
$registry = new ToolRegistry(new NullLogger());
(new ToolScanner($registry))->scan(new NoteTools());
$result = $registry->call('list_notes', ['page' => 1], ToolContext::cli());
$result->success; // true
$result->data; // ['notes' => [], 'page' => 1]
$result->toJson(); // {"success":true,"data":{...},"message":null,"error":null,"meta":{...}}No ToolContext is required — call() defaults to ToolContext::cli() (full-access,
for scripts and tests). Real hosts build one per channel: ToolContext::mcp($requestId, $principal, $scopes) for an authenticated MCP caller, ToolContext::stdio($requestId) for a
trusted local stdio MCP server process (no per-caller auth — see
Authorize below), ToolContext::telegram($chatId, $userId), or a custom
new ToolContext(...) for a web session.
A PHP array has no native type that maps to JSON-Schema type: object on its own —
without an override, ToolScanner infers array -> schema type: array, and
SchemaValidator then requires that value to be a JSON list, rejecting an associative
payload like {"post_id": 1} outright. Pass type: 'object' on #[Param] to opt a PHP
array $param into type: object instead; add properties/requiredProperties to declare
its shape (both optional — omit them for an open object with no declared shape):
#[Tool('update_post', 'Update fields on a post')]
public function updatePost(
int $post_id,
#[Param('Fields to update', type: 'object', properties: [
'title' => ['type' => 'string'],
'body' => ['type' => 'string'],
])]
array $updates
): ToolResult {
// $updates arrives as a plain associative array — ['title' => ..., 'body' => ...] —
// no manual json_decode() needed; the host's JSON transport already decoded it that way.
return ToolResult::success(['post_id' => $post_id, 'updates' => $updates]);
}This is purely opt-in (tool-runtime 0.6): a bare array $param with no #[Param(type: ...)]
override keeps generating type: array and keeps requiring a list, exactly as before.
ToolScanner takes each wire argument name straight from ReflectionParameter::getName() —
there is no snake_case conversion. A PHP parameter string $instanceId produces the schema
property instanceId, not instance_id; a caller sending instance_id gets a "missing
required field" error. Every single-word parameter is unaffected (there is nothing to
convert), but a multi-word wire name must be matched by naming the PHP parameter itself in
that exact case — e.g. string $instance_id for a snake_case-conventioned tool family, or
string $instanceId for a camelCase one. Pick the PHP parameter name to match your tool
family's own wire convention; #[Param]'s description/type/etc. do not rename the
property.
Every ToolRegistry::call() runs the same six steps, in order, regardless of who is
calling — a human over cli, an LLM over mcp, or a bot over telegram:
-
Resolve — look up the tool by name; an unknown name is a typed
ToolResult::error()(ToolResult::TOOL_NOT_FOUND), never an exception. -
Validate —
SchemaValidatorchecks the arguments against the tool's JSON input schema (required fields, types), then applies numericclampsbefore execution. -
Authorize —
PolicyGatechecks the caller'sToolContextscopes against the tool's required scopes, then falls back to per-channel policy (cliallows all,mcpandwebrequire auth by default). A host can plug inPolicyRuleProviderInterfacefor database-backed rules, and an optionalRateLimiterInterfacethrottles bychannel:principal:tool. -
Confirm (mutating tools only) — a tool declared
confirm: true(or matching a channel'srequire_confirmation_for_mutatingpolicy) returns aconfirm_tokenon the first call instead of executing; the caller replays the same arguments plus that token to proceed.ConfirmationTokenStoreholds the pending action and its expiry.The redemption contract, precisely: on the first call,
ConfirmationTokenStore::create()snapshots the exact args of that call (name + args + a 60s-default expiry) and hands back a random token. The caller is expected to replay the same arguments plusconfirm_tokenon the second call — but the runtime does not diff or validate that replay:ToolRegistry::call()stripsconfirm_tokenoff the incoming args, callsConfirmationTokenStore::consume($token, $name), and — if the token is valid, unexpired, and minted for this tool name — discards whatever args the second call actually sent and executes with the args stored atcreate()time instead. A token is one-time-use (deleted on consume) and matched only by$name, not by argument identity. Practically: the second call's args (other thanconfirm_tokenitself) are inert — the tool executes with the first call's arguments, not the second's. -
Execute — the tool's callback runs with a soft timeout; a bare return value is wrapped in
ToolResult::success()automatically, and an uncaughtThrowablebecomesToolResult::error()(ToolResult::INTERNAL_ERROR) instead of propagating. -
Audit —
ToolAuditLoggerrecords every call (success, failure, or rejection) via PSR-3, redacting sensitive argument fields (password,token,secret, …) before they ever reach a log line. Since 0.5, the success/failure legs of this are event-driven — see Events below.
Since 0.5, one more thing happens between step 4 and step 5: if a MilpaEventDispatcherInterface
is wired, ToolRegistry dispatches tool.executing — a listener may short-circuit the call
(answer on the tool's behalf, e.g. a cache) or veto it outright, both without step 5 ever running.
See Events.
A ToolContext built with mode: 'plan' (or ToolContext::asPlan()) short-circuits after
step 3: it validates and authorizes but never executes, returning the would-be plan instead
— a dry-run for any tool, for free.
Denials say which check failed. When step 3 denies a call, ToolResult::error()'s
message names the specific check and what was missing — not a bare "forbidden". A channel
that requires auth reports "channel 'mcp' requires an authenticated principal (require_auth) — none provided."; a scope mismatch reports "Missing required scope for tool 'resolve_verification'. Need one of: verification:resolve — context has: tasks:write."; a
block_mutating channel policy names the tool and the channel; a PolicyRuleProviderInterface
denial names the rule id, the tool, and the channel; a rate-limit denial names the exact
channel:principal:tool key that hit its budget. The error code stays
ToolResult::FORBIDDEN (or ToolResult::RATE_LIMITED) either way — callers that match on the
code are unaffected; only the message got specific enough to debug from the error alone.
Trusted local stdio MCP servers: a no-auth mcp transport (an editor or agent runtime
spawning your server as a child process, with no separate per-caller identity to authenticate)
should build its ToolContext with ToolContext::stdio($requestId) — it hard-codes
principal: 'stdio' and the wildcard ['*'] scope, the same "process boundary IS the trust
boundary" shape ToolContext::cli() already uses for CLI scripts. This exists because the
mcp channel's built-in policy sets require_auth: true: a bare new ToolContext(channel: 'mcp') (no principal) hits exactly the denial described above, one call at a time, with no
documented way out before this factory existed.
The six steps above are the law that already governs every call. InvocationPlan makes that law
statically visible without executing anything — the read-only counterpart to plan-mode. Plan-mode
dry-runs one call (real args, real authorize, stops before Execute); InvocationPlan x-rays the
wiring itself — for a given tool on a given channel, which steps would run, which are inert, and
why — without a call at all.
InvocationPlanBuilder::build(ToolDefinition $tool, ToolContext $ctx, RegistryWiring $wiring) reads
a live ToolRegistry and emits an InvocationPlan: an ordered list<InvocationStep>, one per
pipeline stage (Inspection\InvocationStepKind enumerates all eleven — the six narrative steps above,
un-collapsed: Validate/Clamp split, PlanMode/Confirm split, plus EmitExecuting,
ContainException, and Audit as distinct stages). Each InvocationStep carries its
InvocationStepRole (Guard, Transform, Branch, Hook, Execution, Boundary, Outcome — so
ContainException is a Boundary that wraps Execute, and Audit is an Outcome that spans several
terminal paths, not a linear last step) and its StepPresence:
Active— runs for this tool+channel.Conditional— may run depending on data known only at call time.Dormant— the rule exists but current static facts make it impossible to fire. The#[Tool]attribute has nomutatingparameter, so every scanned tool ismutating: false, which leavesblock_mutating,require_confirmation_for_mutating, and the 5×-rate-cost permanently Dormant. The plan says so instead of pretending they guard anything.Skipped— the subsystem is not wired (no rate limiter, no dispatcher, no rule provider on this registry).
The plan never guesses — it reports what the registry admits it has, through the read-only accessors
the builder derives presence from: ToolRegistry::hasRateLimiter() / hasDispatcher() and
PolicyGate::channelPolicy() / hasRuleProvider(). And it never fabricates a caller: with no actor
supplied on an auth-requiring channel, the context is genuinely anonymous (principal: null) and the
plan honestly predicts the denial. Its guiding rule (ADR#13): inspection must describe what the
runtime actually executes, not an aspirational pipeline — so Audit's source enumerates its real
coverage (validation-failure, authorization-failure, rate-limit, cache-hit, execute-success,
execute-failure) and, just as honestly, the four terminal paths it does not audit (resolve-miss,
plan-mode, confirmation-request, veto).
coa:tools inspect <tool> is the CLI projection of the same InvocationPlan — a host command in the
MilpaToolRuntimePlugin adapter (like coa:tools), not the runtime itself. --channel picks the
channel, --actor/--scope supply a caller to inspect (never fabricated when omitted), and --json
emits the whole plan (schemaVersion, context, assumptions, wiring, steps) for agents.
Since 0.5 (the event-driven retrofit), ToolRegistry accepts an optional
Milpa\Interfaces\Event\MilpaEventDispatcherInterface as its second constructor argument — the
same nullable-dispatcher-in-the-ctor pattern HumanVerifier already uses below. Without one,
call() behaves exactly as it did before 0.5: there is no interception point, and
ToolAuditLogger still logs every call (see below) — "no dispatcher" never means "no audit
trail", it only means "nothing can intercept."
With a dispatcher wired, three events fire around every call:
| Event | When | Carries | Slot? |
|---|---|---|---|
tool.executing |
PRE — after resolve, validate/clamp, PolicyGate::authorize(), rate-limiting, and the confirm-gate have all already run and passed |
Events\ToolExecutingEvent($name, $ctx, $args) |
Yes — a Milpa\Events\InterceptionSlot travels alongside it |
tool.executed |
POST — a call finished, live or via a cache short-circuit | Events\ToolExecutedEvent($name, $ctx, $args, $result, $cacheServed) |
No — readonly notification |
tool.failed |
POST — the tool's own callback threw | Events\ToolFailedEvent($name, $ctx, $args, $exception, $tookMs) |
No — readonly notification |
tool.executing is dispatched from exactly one place inside ToolRegistry::call(): after
every gate — resolve → validate/clamp → authorize → rate-limit → confirm — has already run and
said yes, and before call_user_func() ever invokes the tool's callback. This ordering is
load-bearing, not incidental: a tool.executing listener (a cache plugin, say) only gets a turn
once authorization has already cleared the call. A denied or rate-limited call returns before
tool.executing is ever dispatched — the listener is never invoked for it, full stop. Moving this
dispatch any earlier (e.g. "before validate" or "before authorize") would let a cache hit stand in
for an authorization check that never ran — an auth bypass wearing a cache's clothes.
A listener on tool.executing can answer on the tool's behalf via the InterceptionSlot
dispatched alongside the event — the tool's real callback never runs:
use Milpa\Events\InterceptionSlot;
use Milpa\ToolRuntime\Events\ToolExecutingEvent;
use Milpa\ToolRuntime\ToolResult;
$dispatcher->subscribe('tool.executing', function (string $eventName, array $payload): void {
/** @var ToolExecutingEvent $event */
$event = $payload['event'];
/** @var InterceptionSlot $slot */
$slot = $payload['slot'];
$cached = $myCache->get($event->name, $event->args);
if ($cached !== null) {
// Short-circuit: the real callback never runs; ToolRegistry::call() returns this
// result instead. tool.executed STILL fires afterwards, marked cacheServed: true —
// a cache hit is never invisible to audit/metrics listeners.
$slot->shortCircuit(ToolResult::success($cached));
}
});
$registry = new ToolRegistry($logger, $dispatcher);Because the anchor sits strictly after authorize(), this cache plugin can never answer a
call PolicyGate already denied — the security property, verified end-to-end (including a real
EventDispatcher, a real denied principal, and an assertion that the cache listener's invocation
count stays at zero) in tests/Events/CacheShortCircuitTest.php.
A listener may instead call $slot->stop() (without shortCircuit()) for a pure veto — call()
then returns ToolResult::blocked('Tool execution vetoed by an event listener') without ever
invoking the callback, and without a replacement result.
ToolRegistry's constructor subscribes its internal ToolAuditLogger to tool.executed /
tool.failed whenever a dispatcher is supplied — ToolAuditLogger::onToolExecuted() /
onToolFailed() reproduce the exact log lines the registry used to emit imperatively (including
the soft-timeout warning, now driven off $result->meta['timeout_exceeded'] instead of a direct
call). When no dispatcher is wired, call() invokes those same listener methods directly
instead of going through dispatch() — so "no dispatcher" still means "full audit trail", never
"silently stopped logging."
Validation failures, authorization denials, and rate-limit rejections keep logging exactly as
they did before 0.5 (logValidationFailure() / logAuthFailure() / a direct log() call in the
rate-limit branch) — those all happen before the security anchor, so there is no tool.* event
yet dispatched for them to hang off.
Some actions can't be authorized by scopes alone — they need a human or another agent to
say yes. milpa/core defines the seam: Milpa\Interfaces\Verification\VerifierInterface,
whose verify() returns a VerificationResult that may be PENDING and resolve later.
This package ships the reference implementation:
-
HumanVerifierimplementsVerifierInterface.verify()cannot decide synchronously, so it returnsVerificationResult::pending()and dispatchesverification.requested; a latergrant()/reject()call resolves it and dispatchesverification.granted/verification.rejected. -
VerificationToolexposesHumanVerifieras two tools — the same registry pipeline every other tool runs through, no special-cased transport:request_verification(subject, policy?, requested_by?, request_id?)opens a verification and returns itsrequest_id. Its schema has nodecisionorprincipalfield at all — this tool can never grant or reject anything, only open a request.resolve_verification(request_id, decision, principal, subject?, reason?)resolves a pending one.request_id,decision(grant|reject), andprincipalare required;subjectandreasonare optional. Whensubjectis omitted, the resolvedVerificationRequestis built viaVerificationRequest::forResolution()(subjectstaysnull, identified purely byrequest_id) — the earliersubject = $request_idfallback (tool-runtime 0.3) is gone, so averification.granted/verification.rejectedlistener never sees a fabricated subject. The tool's own success message still showsrequest_idin place ofsubjectfor readability; that is display-only formatting, not theVerificationRequest'ssubjectfield.
Tool-runtime 0.2 shipped this as a single combined tool that mixed both phases behind one schema, distinguished only by whether
decisionwas present — a shape whose name also invited reading it as "the caller can verify itself". 0.3 splits it into the two tools above; the old combined tool no longer exists.Both tools register with
ToolOptions(mutating: true, requiresConfirmation: false)— the registry's generic step-4 confirmation gate (see The pipeline) is deliberately bypassed for both, becausehandleRequest()/handleResolve()together already are the two-phase confirmation protocol (open a request, resolve it later). Stacking the registry's confirm-token gate on top of that would recreate the confusing 3-4 call choreography tool-runtime 0.2 already killed for the combined tool — see Changed in 0.2: the double-gate bypass for that history.⚠️ The bypass is not absolute: a channel whose policy setsrequire_confirmation_for_mutating(the built-intelegrampolicy does) still gates anymutating: truetool viaPolicyGate::requiresConfirmation(), regardless of the tool's ownrequiresConfirmationflag. Oncli,mcp, andweb(none of which set that policy by default) the bypass is total.
Calling either tool via $registry->call() runs its handler directly — no generic
confirm-token wrapper in between. A full request → resolve round trip is exactly two calls,
one per tool:
use Milpa\ToolRuntime\Verification\HumanVerifier;
use Milpa\ToolRuntime\Verification\VerificationTool;
(new VerificationTool(new HumanVerifier()))->register($registry);
$request = $registry->call('request_verification', [
'subject' => 'gate:report.publish',
], $ctx);
// -> ToolResult success, data: [
// 'subject' => 'gate:report.publish', 'policy' => 'single',
// 'request_id' => '06a1dda5-...',
// ]
// handleRequest() ran on THIS call — HumanVerifier::verify() ran and dispatched
// `verification.requested`. No confirm_token anywhere: the registry gate never ran.
$registry->call('resolve_verification', [
'request_id' => $request->data['request_id'],
'decision' => 'grant',
'principal' => 'agent:claude',
], $ctx);
// -> ToolResult success, data: [
// 'status' => 'passed', 'reason' => null, 'verifier' => 'human_verifier',
// 'principal' => 'agent:claude', 'missing' => [], 'metadata' => [],
// ]
// HumanVerifier::grant() ran and dispatched `verification.granted`.Echo the request_id from the first call's response back on resolve_verification — it is
HumanVerifier's own correlation id (#7), not the registry's confirm_token; neither tool
mints or expects a confirm_token.
Because the two phases are separate tools — not one tool with a conditional field — a host's
policy can allow request_verification to any principal that can reach the registry while
restricting resolve_verification to specific principals, using the same scopes mechanism
every other tool in this package uses. VerificationTool's constructor takes an optional
resolveScopes list, applied only to resolve_verification's ToolOptions:
use Milpa\ToolRuntime\Contracts\ToolContext;
use Milpa\ToolRuntime\Verification\HumanVerifier;
use Milpa\ToolRuntime\Verification\VerificationTool;
// request_verification stays open (empty scopes, the pre-split default); resolve_verification
// requires 'verification:resolve' — a scope only reviewer contexts carry.
(new VerificationTool(new HumanVerifier(), resolveScopes: ['verification:resolve']))
->register($registry);
$worker = new ToolContext(principal: 'agent:worker', channel: 'mcp', scopes: ['tasks:write']);
$reviewer = new ToolContext(
principal: 'agent:reviewer',
channel: 'mcp',
scopes: ['tasks:write', 'verification:resolve'],
);
$request = $registry->call('request_verification', ['subject' => 'gate:report.publish'], $worker);
// -> success: $worker has no 'verification:resolve' scope, but request_verification never checks it.
$registry->call('resolve_verification', [
'request_id' => $request->data['request_id'], 'decision' => 'grant', 'principal' => 'agent:worker',
], $worker);
// -> FORBIDDEN: "Missing required scope for tool 'resolve_verification'. Need one of:
// verification:resolve — context has: tasks:write." (see the pipeline's Authorize step
// for the full FORBIDDEN-message-clarity contract)
$registry->call('resolve_verification', [
'request_id' => $request->data['request_id'], 'decision' => 'grant', 'principal' => 'agent:reviewer',
], $reviewer);
// -> success: $reviewer carries the required scope.tests/Verification/VerificationToolPolicyDividendTest.php pins exactly this scenario.
The same request → resolve round trip is also reachable by calling handleRequest() /
handleResolve() directly, independent of any ToolRegistry — useful when you don't have a
registry at hand (e.g. a unit test):
use Milpa\ToolRuntime\Verification\HumanVerifier;
use Milpa\ToolRuntime\Verification\VerificationTool;
$tool = new VerificationTool(new HumanVerifier($eventDispatcher));
$request = $tool->handleRequest(['subject' => 'gate:report.publish']);
// -> ToolResult::confirmation(), $request->data['request_id'] === '06a1dda5-...'
// HumanVerifier::verify() ran and dispatched `verification.requested`.
$tool->handleResolve([
'request_id' => $request->data['request_id'],
'decision' => 'grant',
'principal' => 'agent:claude',
]);
// -> ToolResult::success(), data: ['status' => 'passed', 'principal' => 'agent:claude', ...]
// HumanVerifier::grant() ran and dispatched `verification.granted`.Any other VerifierInterface implementation — a deterministic rule, a quorum vote, an
external approval service — plugs into the same seam.
Before 0.2, the combined verification tool used requiresConfirmation: true, so any call
through ToolRegistry::call() — request or resolve alike — hit the registry's own step-4
confirmation gate before the tool's own handler ever ran. Opening a request took two
registry calls just to reach the request phase (which itself returned a confirmation, this
one carrying request_id) — and resolving it needed a third, itself gated the same way.
The registry's generic wrapper carries no request_id (it knows nothing about
HumanVerifier), so a caller only ever saw the correlation id after redeeming a token they
didn't know they'd need. 0.2 set requiresConfirmation: false instead, since the handler's
own request_id round trip already is the confirmation protocol — the registry's generic
one was pure overhead for this tool specifically. 0.3's split preserves that same
requiresConfirmation: false decision on both request_verification and
resolve_verification — see Verification
above.
HumanVerifier dispatches three events — verification.requested (from verify()),
verification.granted and verification.rejected (from grant() / reject()) — through
the optional MilpaEventDispatcherInterface passed to its constructor. Every dispatch uses
the same payload shape: a single key, 'event', holding the event object, not a
flattened array of the request's fields:
$dispatcher->dispatch('verification.requested', ['event' => $requestedEvent]);
// $requestedEvent instanceof Milpa\Events\VerificationRequestedEvent
$dispatcher->dispatch('verification.granted', ['event' => $grantedEvent]);
// $grantedEvent instanceof Milpa\Events\VerificationGrantedEvent
$dispatcher->dispatch('verification.rejected', ['event' => $rejectedEvent]);
// $rejectedEvent instanceof Milpa\Events\VerificationRejectedEventA listener reaches the data through the event object's accessors, not array keys —
$payload['subject'] is always null/undefined; the subject lives at
$payload['event']->getRequest()->subject:
| Event | Accessors |
|---|---|
VerificationRequestedEvent |
getRequest(): VerificationRequest, getRequestId(): ?string |
VerificationGrantedEvent |
getRequest(): VerificationRequest, getResult(): VerificationResult, getRequestId(): ?string |
VerificationRejectedEvent |
getRequest(): VerificationRequest, getResult(): VerificationResult, getRequestId(): ?string |
A listener that wants to work with any of the three (or with a future verifier's events)
should branch on the event's class, or narrow via getRequest()/getResult(), rather than
assume a flat array — this is defined and enforced by the event classes' own docblocks in
milpa/core (Milpa\Events\Verification{Requested,Granted,Rejected}Event).
| Layer | Package | Owns |
|---|---|---|
| Contracts | milpa/core |
ToolProviderInterface, ToolRegistryInterface, VerifierInterface, capability/verification value objects and events — the seams, not the engine. |
| Runtime | milpa/tool-runtime (this package) |
The concrete ToolRegistry pipeline, #[Tool]/#[Param] attributes + ToolScanner, SchemaValidator, PolicyGate, rate limiting, channel rendering, ToolAuditLogger, the HumanVerifier reference verifier (request_verification / resolve_verification), and the read-only Inspection\ pipeline model (InvocationPlanBuilder → InvocationPlan) behind coa:tools inspect. |
| Your app | your host / plugins | Concrete PolicyRuleProviderInterface (e.g. Doctrine-backed rules), LoggerInterface, channel renderers, and where policy decisions and audit logs are actually persisted. |
The types you construct and pass around day to day:
| Type | What it is |
|---|---|
Contracts\ToolContext |
Who/where/what-scopes for one call — principal, channel, scopes, mode. Named constructors per channel: cli(), mcp(), stdio() (trusted local stdio MCP server), telegram(). |
ToolResult |
The uniform return shape — success, data, message, error, meta. Factories for common shapes: success(), error(), paginated(), detail(), confirmation(), blocked(). |
ToolRegistry |
The pipeline: register() to add a tool by hand, call() to run resolve→validate→authorize→execute→audit, getToolSummaries() (plain-array LLM/MCP wire shape) / getToolDefinitions() (typed list<ToolDefinition>) / getToolsWithinBudget() for LLM/MCP exposure; read-only introspection via hasRateLimiter() / hasDispatcher() (and PolicyGate::channelPolicy() / hasRuleProvider()) — what the Inspection\ builder reads without ever calling. |
Rendering\RendererRegistry |
Picks a ChannelRendererInterface for a ToolResult based on ToolContext::$channel, falling back to a default renderer or raw JSON. |
Contracts\LlmServiceInterface |
The seam a plugin implements to provide LLM access (generateResponse()) and other plugins consume to get one, without depending on a specific provider. |
Events\ToolExecutingEvent / Events\ToolExecutedEvent / Events\ToolFailedEvent |
The three tool.* event VOs (0.5) dispatched around ToolRegistry::call() — see Events. |
Inspection\InvocationPlanBuilder → Inspection\InvocationPlan |
Builds a read-only, per-tool/channel x-ray of the pipeline from a live ToolRegistry, executing nothing — an ordered list<InvocationStep> (each tagged by InvocationStepKind / InvocationStepRole / StepPresence) plus RegistryWiring (which optional collaborators are actually plugged in). Projected to the CLI by coa:tools inspect. |
- PHP ≥ 8.3
milpa/core^0.6 (theInterceptionSlot/MilpaEventDispatcherInterfacekeystone the Events seam is built on)psr/log^3milpa/events(optional, dev-only) — the referenceMilpaEventDispatcherInterfaceimplementation; any conformant implementation works, this package has no hard dependency on it
Full API reference: getmilpa.github.io/tool-runtime — generated straight from the source DocBlocks and dressed with the Milpa design system.
Contributions are welcome — see CONTRIBUTING.md. Please report security issues via SECURITY.md, and note that this project follows a Code of Conduct.
Apache-2.0 © Rodrigo Vicente - TeamX Agency.
Milpa is designed, built, and maintained by Rodrigo Vicente - TeamX Agency.