milpa/tool-runtime
Composer 安装命令:
composer require milpa/tool-runtime
包简介
AI tool-execution runtime for the Milpa PHP framework: registry pipeline (resolve, validate, authorize, execute, audit), #[Tool] attributes, schema validation, policy gate, rate limiting, channel rendering, and human verification.
README 文档
README
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.
Install
composer require milpa/tool-runtime
Quick example
Attribute 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), ToolContext::telegram($chatId, $userId), or a custom new ToolContext(...) for a web session.
The pipeline
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. - 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.
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.
Verification: human_verify
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.HumanVerifyToolexposesHumanVerifieras thehuman_verifytool — the same registry pipeline every other tool runs through, no special-cased transport. Itsregister()marks itToolOptions(mutating: true, requiresConfirmation: true), so any call throughToolRegistry::call()— whether it's a request or a resolve — hits the registry's own step-4 confirmation gate (see The pipeline) beforeHumanVerifyTool::handle()ever runs.
Through the registry: the generic confirm-token dance
Calling human_verify via $registry->call() behaves exactly like any other
requiresConfirmation tool: the first call does not run handle(). It mints a
confirm_token and returns the registry's generic wrapper instead — no request_id
anywhere, because this wrapper is generic and knows nothing about HumanVerifier:
use Milpa\ToolRuntime\Verification\HumanVerifier; use Milpa\ToolRuntime\Verification\HumanVerifyTool; (new HumanVerifyTool(new HumanVerifier()))->register($registry); $registry->call('human_verify', [ 'subject' => 'gate:report.publish', 'decision' => 'grant', 'principal' => 'agent:claude', 'request_id' => 'req-123', ], $ctx); // -> ToolResult success, data: [ // 'requires_confirmation' => true, // 'confirm_token' => '7e01bf71...', // 'action_summary' => 'human_verify(subject=gate:report.publish, decision=grant, principal=agent:claude)', // 'expires_at' => '2026-07-07T02:16:26+00:00', // ] // handle() has NOT run — HumanVerifier::grant() has not been called yet. $registry->call('human_verify', [ 'subject' => 'gate:report.publish', 'decision' => 'grant', 'principal' => 'agent:claude', 'request_id' => 'req-123', 'confirm_token' => '7e01bf71...', // from the previous response ], $ctx); // -> ToolResult success, data: [ // 'status' => 'passed', 'reason' => null, 'verifier' => 'human_verify', // 'principal' => 'agent:claude', 'missing' => [], 'metadata' => [], // ] // NOW handle() ran, using the exact args ConfirmationTokenStore stored at create() time // (not whatever you pass on the second call) — this is what actually invoked HumanVerifier::grant().
The second call must replay the same arguments as the first, plus confirm_token;
ConfirmationTokenStore::consume() hands ToolRegistry::call() back the args it stored,
and those — not the ones on the redeeming call — are what handle() receives.
⚠️ Calling human_verify through the registry without a decision (to open a request)
hits the same gate: the first call only returns a confirm_token, and redeeming it invokes
handle() with no decision, which — being HumanVerifyTool's own request phase — returns
another confirmation, this one carrying request_id. Resolving that request then needs a
second confirm-token round trip (this time with decision in the args) — four registry
calls end-to-end. For the two-phase request_id flow HumanVerifyTool was built around,
skip the registry and call the tool directly — see below.
Direct usage: the two-phase request_id flow
HumanVerifyTool's request → resolve round trip (open a request, get a request_id,
resolve it later with that id) is reached by calling handle() directly — this is how the
package's own tests exercise it (tests/Unit/Verification/HumanVerifyToolTest.php), and
it's the sanctioned way to drive D8 verification programmatically, independent of the
registry's confirmation gate:
use Milpa\ToolRuntime\Verification\HumanVerifier; use Milpa\ToolRuntime\Verification\HumanVerifyTool; $tool = new HumanVerifyTool(new HumanVerifier($eventDispatcher)); $request = $tool->handle(['subject' => 'gate:report.publish']); // -> ToolResult::confirmation(), $request->data['request_id'] === '06a1dda5-...' // HumanVerifier::verify() ran and dispatched `verification.requested`. $tool->handle([ 'subject' => 'gate:report.publish', 'decision' => 'grant', 'principal' => 'agent:claude', 'request_id' => $request->data['request_id'], ]); // -> 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.
What lives where
| 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, and the human_verify reference verifier. |
| 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. |
API de facto
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(), 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, getTools() / getToolsWithinBudget() for LLM/MCP exposure. |
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. |
Requirements
- PHP ≥ 8.3
milpa/core^0.2psr/log^3
Contributing
Contributions are welcome — see CONTRIBUTING.md. Please report security issues via SECURITY.md, and note that this project follows a Code of Conduct.
License
Apache-2.0 © the Milpa authors.
Milpa is designed, built, and maintained by TeamX Agency.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 1
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: Apache-2.0
- 更新时间: 2026-07-07