feat(extension): a plugin runtime can give the agent new tools

A runtime could only replace a tool by name, and that slot was never routed
to it, so a plugin that wanted to add one had to ship an MCP server. A
runtime now declares tools in its manifest, under runtime.tools with the
tools capability: a name, a description and an object input schema, known
before the process starts and shown in the install plan and the package
list. At initialize the runtime names the declared tools it serves; one the
manifest does not describe fails the handshake as capability_not_declared.

The model sees each as ext__<plugin>__<tool> and reaches it through the
capability catalog the way it reaches MCP tools, so installing or reloading
a plugin never moves the cached prompt prefix, and every call passes the
permission check under that name. A call is the new host request
extension/tool/call, bounded at five minutes; isError carries a failure the
tool reports about its own work, apart from a call that could not run.
A runtime that failed to start contributes no tools.

The Go SDK serves them from Options.Tools and names them in the handshake.
An effect test drives a call through the real assembly to a fake runtime and
back to the model, and fails with the registration removed.
This commit is contained in:
yhh committed 2026-09-23 17:43:26 -07:00
1 parent 2c7bde8710
commit d318d24050
40 files changed
+732 -105

No files matched your search

+4
View File
@@ -1135,4 +1135,8 @@ export const EN: Record<string, string> = {
"The model returned nothing; try again",
"优化请求格式不正确":
"The refine request was malformed",
"新增工具":
"New tools",
"agent 可以调用,每次调用照常经过权限确认":
"The agent can call them; every call still goes through permission checks",
};
+2
View File
@@ -35,6 +35,8 @@ export interface PluginRuntime {
intercepts?: string[];
replaces?: string[];
capabilities?: string[];
// Tools the process serves to the agent, by the name its manifest gives.
tools?: string[];
}
export interface PluginSkipped {
@@ -290,6 +290,9 @@ function executes(a: PluginAction): { label: string; detail: string; why?: strin
why: "运行于 Reasonix 内部,可读取整个会话、绕过权限并直接操作本机",
});
}
if (a.runtime?.tools?.length) {
out.push({ label: "新增工具", detail: a.runtime.tools.join("、"), why: "agent 可以调用,每次调用照常经过权限确认" });
}
if (a.hookCount) {
out.push({ label: "自动化钩子", detail: `${a.hookCount} 条`, why: "在会话生命周期内自动执行" });
}
@@ -316,6 +319,8 @@ function newlyGained(current: PluginPackage, actions: PluginAction[]): string[]
const plugin = actions.find((a) => a.kind === "plugin");
const out: string[] = [];
if (plugin?.runtime && !current.runtime) out.push("一个常驻进程");
const tools = (plugin?.runtime?.tools ?? []).filter((name) => !current.runtime?.tools?.includes(name));
if (tools.length > 0) out.push(`工具 ${tools.join("、")}`);
const hooks = (plugin?.hookCount ?? 0) - (current.hooks?.length ?? 0);
if (hooks > 0) out.push(`${hooks} 条钩子`);
const servers = (plugin?.toolCount ?? 0) - (current.mcpServers?.length ?? 0);
@@ -187,6 +187,13 @@ function Package({
<span className="sc">{[p.runtime.command, ...(p.runtime.args ?? [])].join(" ")}</span>
</div>
)}
{p.runtime?.tools?.map((name) => (
<div className="row" data-run key={"tool:" + name}>
<span className="d">▸</span>
<span>{t("工具")}</span>
<span className="sc">{name}</span>
</div>
))}
{p.hooks?.map((h) => (
<div className="row" data-run key={h.event + h.command}>
<span className="d">▸</span>
+3
View File
@@ -50,6 +50,9 @@ install runtimes you trust completely.
The ref works everywhere a built-in ref does: `default_model`, `--model`,
the CLI/Desktop/ACP pickers, and mid-session model switches — including on
the very first boot.
- **Tools** — model-callable tools declared under `runtime.tools`, offered
to the model as `ext__<plugin>__<tool>` through the capability catalog and
called with the same permission checks as any other tool.
- **Structured UI** — status entries, cards, forms, and notifications
rendered natively in the CLI transcript, the Desktop app, and ACP clients
(with text fallbacks), plus `/<plugin>:<action>` actions in the slash
+2 -1
View File
@@ -5,7 +5,7 @@
- Protocol ID: `reasonix.extension.v2`
- Protocol major: `2`
- Schema: `internal/extension/protocol/schema.generated.json`
- Schema hash: `sha256:c19003186419665354115b5a71e525423303edf788b0738ebcbfd6427732001b`
- Schema hash: `sha256:085c014bb6d1713f722177dda5d79b1224d9bf611cb3947d3331aeb0768449fa`
Within major v2 only optional fields, new enum values, and new methods may
be added; existing required fields, directions, limits, error reasons, and
@@ -26,6 +26,7 @@ semantics never change.
| `extension/provider/stream/open` | `host_to_extension_request` | `provider` | `StreamOpenParams` | `StreamOpenResult` |
| `extension/resources/changed` | `host_to_extension_notification` | `observation` | `ResourcesChangedParams` | `-` |
| `extension/shutdown` | `host_to_extension_request` | `lifecycle` | `ShutdownParams` | `ShutdownResult` |
| `extension/tool/call` | `host_to_extension_request` | `tool` | `ToolCallParams` | `ToolCallResult` |
| `extension/ui/action` | `host_to_extension_request` | `ui` | `UIActionParams` | `UIActionResult` |
| `extension/ui/submit` | `host_to_extension_request` | `ui` | `UISubmitParams` | `UISubmitResult` |
| `host/content/read` | `extension_to_host_request` | `content` | `ContentReadParams` | `ContentReadResult` |
+21
View File
@@ -130,6 +130,27 @@ Actions declared at initialize are namespaced `/<plugin>:<action>` and are
invoked via `extension/ui/action`; form submissions arrive via
`extension/ui/submit`.
## Tools
A runtime with the `tools` capability serves model-callable tools:
- **Declared in the manifest.** Each tool's name, description and input
schema sit under `runtime.tools`, so the host knows them before the process
starts and an install plan shows them.
- **Activated at initialize.** `InitializeResult.tools` names the declared
tools this process serves; a name the manifest does not declare fails the
handshake with `capability_not_declared`. `ManifestExpectation.tools`
tells the extension which names the host will accept.
- **Called through the host.** The model sees each tool as
`ext__<plugin>__<tool>` and reaches it through the capability catalog, as
it does MCP tools, so installing a plugin never moves the cached prompt
prefix. Every call passes the host's permission check first.
- **Answered by `extension/tool/call`.** Params carry the declared `name`,
the model's `arguments` and a `timeoutMillis` budget (5 minutes today).
- **Two kinds of failure.** The result's `content` is what the model reads,
and `isError` marks a failure the tool reports about its own work. A call
the extension cannot run at all answers with a protocol error instead.
## Errors
Domain errors travel as JSON-RPC error code `-32000` with structured data
+24 -3
View File
@@ -292,7 +292,19 @@ Native Reasonix extensions use the exact v2 `apiVersion`:
"priority": 0,
"intercepts": ["input.receive", "tool.before"],
"replaces": [],
"capabilities": ["interceptors"]
"capabilities": ["interceptors", "tools"],
"tools": [
{
"name": "glossary_lookup",
"description": "Look a term up in the team glossary",
"inputSchema": {
"type": "object",
"properties": { "term": { "type": "string" } },
"required": ["term"]
},
"readOnly": true
}
]
}
}
```
@@ -352,8 +364,17 @@ see `docs/EXTENSION_PROTOCOL.generated.md` for the method index and
slot has exactly one owner across all installed plugins; a collision fails
the build with both sources named.
- `capabilities` gates whole feature families: `interceptors`, `strategies`,
`providers`, `ui`. Anything the sidecar announces beyond the manifest is
rejected during the handshake.
`providers`, `ui`, `tools`. Anything the sidecar announces beyond the
manifest is rejected during the handshake.
- `tools` declares the model-callable tools the runtime serves; it needs the
`tools` capability. Each tool has:
- a `name` of 1-48 letters, digits, `_` or `-`, unique in the package;
- a `description` the model chooses the tool by;
- an `inputSchema`, a JSON schema whose `type` is `object`;
- an optional `readOnly`, the author's claim that the tool changes nothing.
- The model sees a tool as `ext__<plugin>__<name>` and calls it through
`use_capability`; every call passes the normal permission check. The
install plan and the package list show the tools a runtime brings.
- Provider models contributed by an extension appear as
`plugin/<plugin>/<provider>/<model>` in the model picker; the ref also
works as `default_model` (including on first boot) and in `/model`,
+2 -44
View File
@@ -858,11 +858,9 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
capLedger = capability.NewLedger()
capAudit = &capability.Audit{}
capProxy = capRuntime.NewFrontend(capLedger, capAudit)
addExtensionTools(reg, extensionMgr, extWarn)
reg.Add(capProxy)
prompt.skillStore.ConfigureInvocationPolicy(func(requires []string) []string {
_, missing := catalogFn().RequiresReady(requires)
return missing
})
gateSkillsOnCatalog(prompt.skillStore, catalogFn)
execSess := newObservedSession(prompt.prompt)
triageProv, triageRef, triagePrice := resolveTriage(cfg, modelRef, proxySpec)
@@ -1581,46 +1579,6 @@ func subagentEffectiveIdentity(cfg *config.Config, resolver provider.Resolver, b
return modelID, strings.TrimSpace(config.EffectiveEffort(&entry))
}
// NewProviderWithProxy builds a provider.Provider with the configured ordinary
// network proxy settings.
func NewProviderWithProxy(e *config.ProviderEntry, proxy netclient.ProxySpec) (provider.Provider, error) {
return provider.New(e.Kind, providerConfig(e, proxy))
}
func providerConfig(e *config.ProviderEntry, proxy netclient.ProxySpec) provider.Config {
return provider.Config{
Name: e.Name,
BaseURL: e.BaseURL,
Model: e.Model,
APIKey: e.APIKey(), APIKeyFunc: e.APIKey, // live: a replaced key reaches the next request
// Pass the key's env var so auth failures can name where to fix it, plus
// provider-kind-specific knobs. EffectiveEffort applies a configured
// default_effort when the user has not explicitly selected /effort.
Extra: map[string]any{
"api_key_env": e.APIKeyEnv,
"api_key_source": e.APIKeySourceLabel(),
"thinking": e.Thinking,
"effort": config.EffectiveEffort(e),
"supported_efforts": e.SupportedEfforts,
"reasoning_protocol": config.ReasoningProtocolForEntry(e),
"max_output_tokens": e.MaxOutputTokens,
"chat_url": e.ChatURL,
"request_url": e.RequestURL,
"headers": e.Headers,
"extra_body": e.ExtraBody,
"auth_header": e.AuthHeader,
"proxy_spec": proxy,
"vision": config.EffectiveVision(e),
"vision_detail": e.VisionDetail,
"web_search": config.EffectiveWebSearch(e),
"mode": e.ResponsesMode,
// Keep nil as nil so the responses provider can vendor-detect its
// default instead of accidentally treating every endpoint as stateful.
"stateful": e.ResponsesStateful,
},
}
}
// addBuiltins adds enabled built-in tools to reg. An empty list means all of
// them. writeRoots confines the file-writing built-ins to the workspace: after
// the (unconfined) defaults are added, each enabled writer is replaced by an
@@ -0,0 +1,69 @@
package boot
import (
"context"
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/event"
"reasonix/internal/provider"
)
// A tool a plugin runtime serves reaches the model the way an MCP tool does:
// absent from the provider-visible list, so the cached prefix never moves, and
// callable through use_capability, where the call reaches the runtime and its
// answer comes back as the tool result.
func TestEffectAnExtensionToolIsCalledThroughTheCatalog(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
installBootFakePlugin(t, config.ReasonixHomeDir(), "lexicon", map[string]any{
"capabilities": []string{"tools"},
"tools": []map[string]any{{
"name": "lookup", "description": "Look a term up in the team glossary", "readOnly": true,
"inputSchema": map[string]any{"type": "object", "properties": map[string]any{"term": map[string]any{"type": "string"}}},
}},
"env": map[string]string{bootFakeEnvInitResult: `{"protocolVersion":"2","name":"lexicon","version":"1.0.0","stateSchemaVersion":0,"tools":["lookup"]}`},
})
rec := &browserScriptProvider{rounds: []func(string) *provider.ToolCall{
func(string) *provider.ToolCall {
return browserCall("c1", "use_capability", map[string]any{
"action": "call", "capability_id": "tool:ext__lexicon__lookup", "arguments": map[string]any{"term": "lease"},
})
},
}}
kind := "boot-exttool-" + strings.ToLower(t.Name())
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return rec, nil })
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
tool_approval = "yolo"
[agent]
system_prompt = "BASE"
[codegraph]
enabled = false
[[providers]]
name = "test-model"
kind = "`+kind+`"
model = "x"
`)
ctrl, err := Build(context.Background(), Options{Sink: event.Discard})
if err != nil {
t.Fatalf("Build: %v", err)
}
if err := ctrl.Run(context.Background(), "what does lease mean here"); err != nil {
t.Fatalf("Run: %v", err)
}
ctrl.Close()
reqs := agentRequests(rec.requests())
if toolNames(reqs[0])["ext__lexicon__lookup"] {
t.Fatal("the extension tool is in the provider-visible schema, where installing a plugin would move the cached prefix")
}
results := effectToolResults(reqs[len(reqs)-1])
if len(results) != 1 || !strings.Contains(results[0], `looked up {"term":"lease"}`) {
t.Fatalf("tool results = %q, want the runtime's answer to the model's arguments", results)
}
}
+7
View File
@@ -176,6 +176,13 @@ func runBootFakeSidecar(stdin io.Reader, stdout io.Writer) {
result = `{"accepted":true,"message":"boot fake action ran"}`
case "extension/ui/submit":
result = `{"accepted":true}`
case "extension/tool/call":
var params struct {
Arguments json.RawMessage `json:"arguments"`
}
_ = json.Unmarshal(frame.Params, &params)
content, _ := json.Marshal("looked up " + string(params.Arguments))
result = fmt.Sprintf(`{"content":%s}`, content)
case "extension/intercept":
result = bootFakeInterceptAnswer(frame.Params)
case "extension/event":
+80
View File
@@ -0,0 +1,80 @@
package boot
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"reasonix/internal/extension/sidecar"
"reasonix/internal/pluginpkg"
"reasonix/internal/tool"
)
// ExtensionToolPrefix marks a tool a plugin runtime serves, the way mcp__
// marks one an MCP server does.
const ExtensionToolPrefix = "ext__"
// extensionToolTimeout bounds one call; a tool doing longer work should
// report progress through its own surfaces and return.
const extensionToolTimeout = 5 * time.Minute
// extensionTool is a tool a plugin runtime serves. Like an MCP tool it is not
// in the provider-visible list: the model finds it through the capability
// catalog, so installing or reloading a plugin never moves the cached prefix.
type extensionTool struct {
name string
pluginID string
decl pluginpkg.RuntimeTool
clients *sidecar.Manager
}
func (t extensionTool) Name() string { return t.name }
func (t extensionTool) Description() string { return t.decl.Description }
func (t extensionTool) Schema() json.RawMessage { return t.decl.InputSchema }
func (t extensionTool) ReadOnly() bool { return t.decl.ReadOnly }
func (t extensionTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
client := t.clients.Client(t.pluginID)
if client == nil || client.Exited() {
return "", fmt.Errorf("the %s extension is not running; reload extensions or check it under Settings → Plugins", t.pluginID)
}
res, err := client.CallTool(ctx, t.decl.Name, args, extensionToolTimeout)
if err != nil {
return "", fmt.Errorf("the %s extension could not run %s: %w", t.pluginID, t.decl.Name, err)
}
if res.IsError {
return "", errors.New(res.Content)
}
return res.Content, nil
}
// extensionToolName qualifies a runtime's tool with its plugin, so two plugins
// can each serve a "search" and neither can take a built-in's name.
func extensionToolName(pluginID, toolName string) string {
return ExtensionToolPrefix + strings.NewReplacer(".", "_").Replace(pluginID) + "__" + toolName
}
// addExtensionTools registers the tools each running runtime declared in its
// manifest and activated at its handshake. A runtime that failed to start
// contributes none, so the catalog never lists a tool nothing can run.
func addExtensionTools(reg *tool.Registry, mgr *sidecar.Manager, warn func(string)) {
if mgr == nil {
return
}
for _, client := range mgr.Clients() {
for _, decl := range client.DeclaredTools() {
if !client.ServesTool(decl.Name) {
continue
}
name := extensionToolName(client.PluginID(), decl.Name)
if len(name) > 64 {
warn(fmt.Sprintf("extension %s: tool %s is not offered: %s is over the 64 characters a model tool name may have", client.PluginID(), decl.Name, name))
continue
}
reg.Add(extensionTool{name: name, pluginID: client.PluginID(), decl: decl, clients: mgr})
}
}
}
+10
View File
@@ -2,6 +2,7 @@ package boot
import (
"context"
"reasonix/internal/capability"
"runtime"
"strings"
@@ -111,3 +112,12 @@ func buildPromptAssembly(ctx context.Context, opts Options, cfg *config.Config,
implicitSkills: implicit,
}, nil
}
// gateSkillsOnCatalog lets a skill be invoked only once what it requires is
// ready in the capability catalog.
func gateSkillsOnCatalog(store *skill.Store, catalog func() capability.Catalog) {
store.ConfigureInvocationPolicy(func(requires []string) []string {
_, missing := catalog().RequiresReady(requires)
return missing
})
}
+47
View File
@@ -0,0 +1,47 @@
package boot
import (
"reasonix/internal/config"
"reasonix/internal/netclient"
"reasonix/internal/provider"
)
// NewProviderWithProxy builds a provider.Provider with the configured ordinary
// network proxy settings.
func NewProviderWithProxy(e *config.ProviderEntry, proxy netclient.ProxySpec) (provider.Provider, error) {
return provider.New(e.Kind, providerConfig(e, proxy))
}
func providerConfig(e *config.ProviderEntry, proxy netclient.ProxySpec) provider.Config {
return provider.Config{
Name: e.Name,
BaseURL: e.BaseURL,
Model: e.Model,
APIKey: e.APIKey(), APIKeyFunc: e.APIKey, // live: a replaced key reaches the next request
// Pass the key's env var so auth failures can name where to fix it, plus
// provider-kind-specific knobs. EffectiveEffort applies a configured
// default_effort when the user has not explicitly selected /effort.
Extra: map[string]any{
"api_key_env": e.APIKeyEnv,
"api_key_source": e.APIKeySourceLabel(),
"thinking": e.Thinking,
"effort": config.EffectiveEffort(e),
"supported_efforts": e.SupportedEfforts,
"reasoning_protocol": config.ReasoningProtocolForEntry(e),
"max_output_tokens": e.MaxOutputTokens,
"chat_url": e.ChatURL,
"request_url": e.RequestURL,
"headers": e.Headers,
"extra_body": e.ExtraBody,
"auth_header": e.AuthHeader,
"proxy_spec": proxy,
"vision": config.EffectiveVision(e),
"vision_detail": e.VisionDetail,
"web_search": config.EffectiveWebSearch(e),
"mode": e.ResponsesMode,
// Keep nil as nil so the responses provider can vendor-detect its
// default instead of accidentally treating every endpoint as stateful.
"stateful": e.ResponsesStateful,
},
}
}
@@ -48,6 +48,7 @@ type ManifestExpectation struct {
Replaces []string `json:"replaces,omitempty"`
Providers []string `json:"providers,omitempty"`
UIActions []string `json:"uiActions,omitempty"`
Tools []string `json:"tools,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
Requires []RequirementWire `json:"requires,omitempty"`
Provides []CapabilityWire `json:"provides,omitempty"`
@@ -81,6 +82,7 @@ type InitializeResult struct {
Replaces []string `json:"replaces,omitempty"`
Providers []ProviderDescriptor `json:"providers,omitempty"`
UIActions []UIActionDecl `json:"uiActions,omitempty"`
Tools []string `json:"tools,omitempty"`
Requires []RequirementWire `json:"requires,omitempty"`
Provides []CapabilityWire `json:"provides,omitempty"`
StateSchemaVersion int `json:"stateSchemaVersion" validate:"min=0"`
+4
View File
@@ -136,6 +136,10 @@ var methodFixtures = map[Method]struct {
},
result: UISubmitResult{Accepted: true},
},
MethodExtensionToolCall: {
params: ToolCallParams{Name: "lookup", Arguments: json.RawMessage(`{"q":"x"}`), TimeoutMillis: 120000},
result: ToolCallResult{Content: "found 3", IsError: true},
},
MethodHostUIPublish: {
params: UIPublishParams{
SurfaceID: "card-1", SessionID: "s-1", Generation: 3,
+23
View File
@@ -0,0 +1,23 @@
package protocol
import "encoding/json"
// Tool DTOs: a model-callable tool the extension declared in its manifest and
// activated at initialize. The host owns the tool's model-visible name, its
// permission check and its timeout; the extension only runs the call.
// ToolCallParams runs one call of a declared tool. Name is the tool as the
// manifest declares it, not the host-qualified name the model sees.
type ToolCallParams struct {
Name string `json:"name" validate:"nonempty"`
Arguments json.RawMessage `json:"arguments"`
TimeoutMillis int `json:"timeoutMillis" validate:"min=0"`
}
// ToolCallResult is what the model reads as the tool's output. IsError marks a
// failure the tool reports about its own work, which the model can act on; a
// call the extension could not run at all is a protocol error instead.
type ToolCallResult struct {
Content string `json:"content"`
IsError bool `json:"isError,omitempty"`
}
+1
View File
@@ -33,6 +33,7 @@ const (
ClassProvider OperationClass = "provider"
ClassUI OperationClass = "ui"
ClassContent OperationClass = "content"
ClassTool OperationClass = "tool"
)
// InterceptEvent names one of the 17 frozen kernel hook points an extension
+7 -3
View File
@@ -33,6 +33,9 @@ const (
MethodHostUIPublish Method = "host/ui/publish"
MethodHostUIRequest Method = "host/ui/request"
// Tools the extension declared, called by the model through the host.
MethodExtensionToolCall Method = "extension/tool/call"
// Externalized content reads (Extension → Host).
MethodHostContentRead Method = "host/content/read"
)
@@ -85,6 +88,7 @@ var frozenRegistry = []MethodSpec{
extensionNotification[StreamEndParams](MethodExtensionProviderStreamEnd, ClassProvider),
hostRequest[UIActionParams, UIActionResult](MethodExtensionUIAction, ClassUI),
hostRequest[UISubmitParams, UISubmitResult](MethodExtensionUISubmit, ClassUI),
hostRequest[ToolCallParams, ToolCallResult](MethodExtensionToolCall, ClassTool),
extensionRequest[UIPublishParams, UIPublishResult](MethodHostUIPublish, ClassUI),
extensionRequest[UIRequestParams, UIRequestResult](MethodHostUIRequest, ClassUI),
extensionRequest[ContentReadParams, ContentReadResult](MethodHostContentRead, ClassContent),
@@ -172,12 +176,12 @@ func ValidateRegistry() error {
return fmt.Errorf("method %s has invalid direction %q", spec.Name, spec.Direction)
}
}
// Extension Protocol v2: 8 lifecycle/intercept/provider/UI Host →
// Extension Protocol v2: 9 lifecycle/intercept/provider/UI/tool Host →
// Extension requests, 3 Extension → Host requests (UI publish/request,
// content read), 3 Host → Extension notifications, 2 provider stream
// notifications.
if len(frozenRegistry) != 16 || hostReq != 8 || extReq != 3 || hostNotif != 3 || extNotif != 2 {
return fmt.Errorf("registry count = total=%d hostReq=%d extReq=%d hostNotif=%d extNotif=%d, want 16/8/3/3/2",
if len(frozenRegistry) != 17 || hostReq != 9 || extReq != 3 || hostNotif != 3 || extNotif != 2 {
return fmt.Errorf("registry count = total=%d hostReq=%d extReq=%d hostNotif=%d extNotif=%d, want 17/9/3/3/2",
len(frozenRegistry), hostReq, extReq, hostNotif, extNotif)
}
return nil
+5 -4
View File
@@ -10,8 +10,8 @@ func TestRegistryIsSortedAndPinned(t *testing.T) {
t.Fatalf("ValidateRegistry: %v", err)
}
registry := Registry()
if len(registry) != 16 {
t.Fatalf("registry has %d methods, want 16", len(registry))
if len(registry) != 17 {
t.Fatalf("registry has %d methods, want 17", len(registry))
}
for i := 1; i < len(registry); i++ {
if registry[i-1].Name >= registry[i].Name {
@@ -35,12 +35,13 @@ func TestRegistryMethodDirections(t *testing.T) {
MethodExtensionProviderStreamEnd: DirectionExtensionToHostNotification,
MethodExtensionUIAction: DirectionHostToExtensionRequest,
MethodExtensionUISubmit: DirectionHostToExtensionRequest,
MethodExtensionToolCall: DirectionHostToExtensionRequest,
MethodHostUIPublish: DirectionExtensionToHostRequest,
MethodHostUIRequest: DirectionExtensionToHostRequest,
MethodHostContentRead: DirectionExtensionToHostRequest,
}
if len(want) != 16 {
t.Fatalf("test pins %d methods, want 16", len(want))
if len(want) != 17 {
t.Fatalf("test pins %d methods, want 17", len(want))
}
for method, direction := range want {
spec, ok := LookupMethod(method)
@@ -1 +1 @@
{"$defs":{"CapabilityWire":{"additionalProperties":false,"properties":{"id":{"minLength":1,"type":"string"},"kind":{"minLength":1,"type":"string"},"namespace":{"minLength":1,"type":"string"},"schemaHash":{"type":"string"},"version":{"type":"string"}},"required":["id","kind","namespace"],"type":"object"},"ContentReadParams":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"offset":{"minimum":0,"type":"integer"}},"required":["contentRef","offset"],"type":"object"},"ContentReadResult":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"dataBase64":{"type":"string"},"encoding":{"enum":["utf8"],"type":"string"},"nextOffset":{"minimum":0,"type":"integer"},"offset":{"minimum":0,"type":"integer"},"sha256":{"type":"string"},"totalBytes":{"minimum":0,"type":"integer"}},"required":["contentRef","dataBase64","encoding","offset","sha256","totalBytes"],"type":"object"},"EventParams":{"additionalProperties":false,"properties":{"event":{"enum":["agent.before_start","compaction.complete","compaction.prepare","context.prepare","frontend.event","input.receive","permission.decision","provider.request","provider.response","session.end","session.load","session.rotate","session.save","session.start","system_prompt.build","tool.after","tool.before"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"payload":{"x-externalizable":true}},"required":["event","payload"],"type":"object"},"ExternalizedField":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"jsonPointer":{"minLength":1,"type":"string"},"sha256":{"type":"string"},"totalBytes":{"minimum":0,"type":"integer"}},"required":["contentRef","jsonPointer","sha256","totalBytes"],"type":"object"},"HostCapabilities":{"additionalProperties":false,"properties":{"contentRefs":{"type":"boolean"},"dependencySchemaVersion":{"minimum":0,"type":"integer"},"protocolVersion":{"minLength":1,"type":"string"},"uiHost":{"enum":["acp","desktop","headless","tui"],"type":"string"}},"required":["contentRefs","protocolVersion","uiHost"],"type":"object"},"InitializeParams":{"additionalProperties":false,"properties":{"capabilities":{"$ref":"#/$defs/HostCapabilities"},"dependencySchemaVersion":{"minimum":0,"type":"integer"},"manifest":{"$ref":"#/$defs/ManifestExpectation"},"protocolId":{"minLength":1,"type":"string"},"protocolVersion":{"minLength":1,"type":"string"},"session":{"$ref":"#/$defs/SessionContext"}},"required":["capabilities","manifest","protocolId","protocolVersion","session"],"type":"object"},"InitializeResult":{"additionalProperties":false,"properties":{"componentId":{"type":"string"},"name":{"minLength":1,"type":"string"},"protocolVersion":{"minLength":1,"type":"string"},"providers":{"items":{"$ref":"#/$defs/ProviderDescriptor"},"type":"array"},"provides":{"items":{"$ref":"#/$defs/CapabilityWire"},"type":"array"},"replaces":{"items":{"type":"string"},"type":"array"},"requires":{"items":{"$ref":"#/$defs/RequirementWire"},"type":"array"},"stateSchemaVersion":{"minimum":0,"type":"integer"},"subscriptions":{"items":{"type":"string"},"type":"array"},"uiActions":{"items":{"$ref":"#/$defs/UIActionDecl"},"type":"array"},"version":{"minLength":1,"type":"string"}},"required":["name","protocolVersion","stateSchemaVersion","version"],"type":"object"},"InitializedParams":{"additionalProperties":false,"properties":{},"type":"object"},"InterceptParams":{"additionalProperties":false,"properties":{"event":{"enum":["agent.before_start","compaction.complete","compaction.prepare","context.prepare","frontend.event","input.receive","permission.decision","provider.request","provider.response","session.end","session.load","session.rotate","session.save","session.start","system_prompt.build","tool.after","tool.before"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"payload":{"x-externalizable":true},"seq":{"minimum":1,"type":"integer"},"timeoutMillis":{"minimum":0,"type":"integer"}},"required":["event","payload","seq","timeoutMillis"],"type":"object"},"InterceptResult":{"additionalProperties":false,"properties":{"decision":{"enum":["allow","block","continue","deny","replace"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"reason":{"type":"string"},"replacement":{"x-externalizable":true}},"required":["decision"],"type":"object"},"ManifestExpectation":{"additionalProperties":false,"properties":{"capabilities":{"items":{"type":"string"},"type":"array"},"intercepts":{"items":{"type":"string"},"type":"array"},"providers":{"items":{"type":"string"},"type":"array"},"provides":{"items":{"$ref":"#/$defs/CapabilityWire"},"type":"array"},"replaces":{"items":{"type":"string"},"type":"array"},"requires":{"items":{"$ref":"#/$defs/RequirementWire"},"type":"array"},"uiActions":{"items":{"type":"string"},"type":"array"}},"type":"object"},"ProviderCatalogParams":{"additionalProperLine truncated
{"$defs":{"CapabilityWire":{"additionalProperties":false,"properties":{"id":{"minLength":1,"type":"string"},"kind":{"minLength":1,"type":"string"},"namespace":{"minLength":1,"type":"string"},"schemaHash":{"type":"string"},"version":{"type":"string"}},"required":["id","kind","namespace"],"type":"object"},"ContentReadParams":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"offset":{"minimum":0,"type":"integer"}},"required":["contentRef","offset"],"type":"object"},"ContentReadResult":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"dataBase64":{"type":"string"},"encoding":{"enum":["utf8"],"type":"string"},"nextOffset":{"minimum":0,"type":"integer"},"offset":{"minimum":0,"type":"integer"},"sha256":{"type":"string"},"totalBytes":{"minimum":0,"type":"integer"}},"required":["contentRef","dataBase64","encoding","offset","sha256","totalBytes"],"type":"object"},"EventParams":{"additionalProperties":false,"properties":{"event":{"enum":["agent.before_start","compaction.complete","compaction.prepare","context.prepare","frontend.event","input.receive","permission.decision","provider.request","provider.response","session.end","session.load","session.rotate","session.save","session.start","system_prompt.build","tool.after","tool.before"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"payload":{"x-externalizable":true}},"required":["event","payload"],"type":"object"},"ExternalizedField":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"jsonPointer":{"minLength":1,"type":"string"},"sha256":{"type":"string"},"totalBytes":{"minimum":0,"type":"integer"}},"required":["contentRef","jsonPointer","sha256","totalBytes"],"type":"object"},"HostCapabilities":{"additionalProperties":false,"properties":{"contentRefs":{"type":"boolean"},"dependencySchemaVersion":{"minimum":0,"type":"integer"},"protocolVersion":{"minLength":1,"type":"string"},"uiHost":{"enum":["acp","desktop","headless","tui"],"type":"string"}},"required":["contentRefs","protocolVersion","uiHost"],"type":"object"},"InitializeParams":{"additionalProperties":false,"properties":{"capabilities":{"$ref":"#/$defs/HostCapabilities"},"dependencySchemaVersion":{"minimum":0,"type":"integer"},"manifest":{"$ref":"#/$defs/ManifestExpectation"},"protocolId":{"minLength":1,"type":"string"},"protocolVersion":{"minLength":1,"type":"string"},"session":{"$ref":"#/$defs/SessionContext"}},"required":["capabilities","manifest","protocolId","protocolVersion","session"],"type":"object"},"InitializeResult":{"additionalProperties":false,"properties":{"componentId":{"type":"string"},"name":{"minLength":1,"type":"string"},"protocolVersion":{"minLength":1,"type":"string"},"providers":{"items":{"$ref":"#/$defs/ProviderDescriptor"},"type":"array"},"provides":{"items":{"$ref":"#/$defs/CapabilityWire"},"type":"array"},"replaces":{"items":{"type":"string"},"type":"array"},"requires":{"items":{"$ref":"#/$defs/RequirementWire"},"type":"array"},"stateSchemaVersion":{"minimum":0,"type":"integer"},"subscriptions":{"items":{"type":"string"},"type":"array"},"tools":{"items":{"type":"string"},"type":"array"},"uiActions":{"items":{"$ref":"#/$defs/UIActionDecl"},"type":"array"},"version":{"minLength":1,"type":"string"}},"required":["name","protocolVersion","stateSchemaVersion","version"],"type":"object"},"InitializedParams":{"additionalProperties":false,"properties":{},"type":"object"},"InterceptParams":{"additionalProperties":false,"properties":{"event":{"enum":["agent.before_start","compaction.complete","compaction.prepare","context.prepare","frontend.event","input.receive","permission.decision","provider.request","provider.response","session.end","session.load","session.rotate","session.save","session.start","system_prompt.build","tool.after","tool.before"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"payload":{"x-externalizable":true},"seq":{"minimum":1,"type":"integer"},"timeoutMillis":{"minimum":0,"type":"integer"}},"required":["event","payload","seq","timeoutMillis"],"type":"object"},"InterceptResult":{"additionalProperties":false,"properties":{"decision":{"enum":["allow","block","continue","deny","replace"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"reason":{"type":"string"},"replacement":{"x-externalizable":true}},"required":["decision"],"type":"object"},"ManifestExpectation":{"additionalProperties":false,"properties":{"capabilities":{"items":{"type":"string"},"type":"array"},"intercepts":{"items":{"type":"string"},"type":"array"},"providers":{"items":{"type":"string"},"type":"array"},"provides":{"items":{"$ref":"#/$defs/CapabilityWire"},"type":"array"},"replaces":{"items":{"type":"string"},"type":"array"},"requires":{"items":{"$ref":"#/$defs/RequirementWire"},"type":"array"},"tools":{"items":{"type":"string"},"type":"array"},"uiActions":{Line truncated
@@ -5,4 +5,4 @@ package protocol
// GeneratedSchemaHash is the SHA-256 of schema.generated.json. Handshake
// comparisons use this constant; protocol tests independently recompute it
// from CanonicalSchemaBytes to reject stale generated artifacts.
const GeneratedSchemaHash = "sha256:c19003186419665354115b5a71e525423303edf788b0738ebcbfd6427732001b"
const GeneratedSchemaHash = "sha256:085c014bb6d1713f722177dda5d79b1224d9bf611cb3947d3331aeb0768449fa"
+2 -2
View File
@@ -78,8 +78,8 @@ func TestSchemaDocumentStructure(t *testing.T) {
if !ok {
t.Fatal("methods is missing")
}
if len(methods) != 16 {
t.Fatalf("methods has %d entries, want 16", len(methods))
if len(methods) != 17 {
t.Fatalf("methods has %d entries, want 17", len(methods))
}
for _, spec := range Registry() {
+3
View File
@@ -409,6 +409,9 @@ func (c *Client) validateHandshakeResult(result protocol.InitializeResult) error
if len(result.UIActions) > 0 && !containsString(rt.Capabilities, "ui") {
return capabilityErr("extension %s declared UI actions without the ui capability", c.pluginID)
}
if err := c.validateTools(result.Tools); err != nil {
return err
}
// Manifest provides is the capability ceiling: handshake must not claim
// capabilities the package never declared. Declared-but-missing provides
// stay Unavailable (no forge) — callers read Status via the lifecycle registry.
+10
View File
@@ -269,6 +269,16 @@ func runFakeSidecar(stdin io.Reader, stdout io.Writer) {
respond(frame.ID, json.RawMessage(`{"accepted":true,"message":`+string(message)+`}`))
case string(protocol.MethodExtensionUISubmit):
respond(frame.ID, json.RawMessage(`{"accepted":true}`))
case string(protocol.MethodExtensionToolCall):
// Echo the call so the host-side test proves which tool ran
// with which arguments; "fail" reports a tool-level error.
var params struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
_ = json.Unmarshal(frame.Params, &params)
content, _ := json.Marshal("ran " + params.Name + " " + string(params.Arguments))
respond(frame.ID, json.RawMessage(fmt.Sprintf(`{"content":%s,"isError":%t}`, content, params.Name == "fail")))
default:
respond(frame.ID, json.RawMessage(`{}`))
}
+1
View File
@@ -30,6 +30,7 @@ func (c *Client) manifestExpectation() protocol.ManifestExpectation {
Intercepts: append([]string(nil), rt.Intercepts...),
Replaces: append([]string(nil), rt.Replaces...),
Capabilities: append([]string(nil), rt.Capabilities...),
Tools: rt.ToolNames(),
Requires: requirementWires(c.requires),
Provides: capabilityWires(c.provides),
}
+71
View File
@@ -0,0 +1,71 @@
package sidecar
import (
"context"
"encoding/json"
"fmt"
"time"
"reasonix/internal/extension/protocol"
"reasonix/internal/pluginpkg"
)
// validateTools holds the tools a runtime activated at initialize to the ones
// its manifest declares: the model is shown the manifest's schemas, so a tool
// the manifest does not describe has nothing the model could have called.
func (c *Client) validateTools(served []string) error {
if len(served) == 0 {
return nil
}
if !containsString(c.rt.Capabilities, "tools") {
return &protocol.ProtocolError{Reason: protocol.ErrCapabilityNotDeclared, Message: fmt.Sprintf("extension %s served tools without the tools capability", c.pluginID)}
}
for _, name := range served {
if !c.declaresTool(name) {
return &protocol.ProtocolError{Reason: protocol.ErrCapabilityNotDeclared, Message: fmt.Sprintf("extension %s served tool %q which its manifest does not declare", c.pluginID, name)}
}
}
return nil
}
func (c *Client) declaresTool(name string) bool {
for _, t := range c.rt.Tools {
if t.Name == name {
return true
}
}
return false
}
// ServesTool reports whether the runtime activated a declared tool.
func (c *Client) ServesTool(name string) bool {
return containsString(c.Handshake().Tools, name)
}
// CallTool runs one call of a declared tool and bounds it by timeout.
func (c *Client) CallTool(ctx context.Context, name string, args json.RawMessage, timeout time.Duration) (protocol.ToolCallResult, error) {
if err := c.readyErr(); err != nil {
return protocol.ToolCallResult{}, err
}
if len(args) == 0 {
args = json.RawMessage(`{}`)
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
raw, err := c.conn.Request(ctx, string(protocol.MethodExtensionToolCall), protocol.ToolCallParams{
Name: name, Arguments: args, TimeoutMillis: int(timeout / time.Millisecond),
})
if err != nil {
return protocol.ToolCallResult{}, mapRequestError(err)
}
decoded, err := protocol.DecodeHostRequestResult(protocol.MethodExtensionToolCall, raw)
if err != nil {
return protocol.ToolCallResult{}, &protocol.ProtocolError{Reason: protocol.ErrProtocolError, Message: "invalid tool result: " + err.Error()}
}
return decoded.(protocol.ToolCallResult), nil
}
// DeclaredTools returns the tools this runtime's manifest declares.
func (c *Client) DeclaredTools() []pluginpkg.RuntimeTool {
return append([]pluginpkg.RuntimeTool(nil), c.rt.Tools...)
}
+65
View File
@@ -0,0 +1,65 @@
package sidecar
import (
"context"
"encoding/json"
"testing"
"time"
"reasonix/internal/extension/protocol"
"reasonix/internal/pluginpkg"
)
func withTools(served string, capability bool) func(rt *pluginpkg.RuntimeSpec) {
return func(rt *pluginpkg.RuntimeSpec) {
if capability {
rt.Capabilities = []string{"tools"}
}
rt.Tools = []pluginpkg.RuntimeTool{
{Name: "lookup", Description: "d", InputSchema: json.RawMessage(`{"type":"object"}`)},
{Name: "fail", Description: "d", InputSchema: json.RawMessage(`{"type":"object"}`)},
}
rt.Env[fakeEnvInitResult] = `{"protocolVersion":"2","name":"fake","version":"1","tools":` + served + `,"stateSchemaVersion":0}`
}
}
// A declared tool runs over the wire with the model's arguments, and a failure
// the tool reports about its own work stays apart from a call that never ran.
func TestADeclaredToolRunsWithItsArguments(t *testing.T) {
client := startFakeClient(t, withTools(`["lookup","fail"]`, true), nil)
if !client.ServesTool("lookup") || client.ServesTool("other") {
t.Fatalf("served = %v", client.Handshake().Tools)
}
if got := client.manifestExpectation().Tools; len(got) != 2 {
t.Fatalf("the manifest expectation named %v, want both declared tools", got)
}
res, err := client.CallTool(context.Background(), "lookup", json.RawMessage(`{"q":"x"}`), 5*time.Second)
if err != nil || res.IsError || res.Content != `ran lookup {"q":"x"}` {
t.Fatalf("CallTool = %+v, %v", res, err)
}
res, err = client.CallTool(context.Background(), "fail", nil, 5*time.Second)
if err != nil || !res.IsError || res.Content != "ran fail {}" {
t.Fatalf("a failing tool = %+v, %v; want its own error, arguments defaulted to {}", res, err)
}
}
// The model is shown the manifest's schemas, so a runtime cannot serve a tool
// its manifest does not describe, nor any tool without the tools capability.
func TestAToolOutsideTheManifestFailsTheHandshake(t *testing.T) {
for name, configure := range map[string]func(*pluginpkg.RuntimeSpec){
"undeclared tool": withTools(`["lookup","rm_rf"]`, true),
"missing capability": withTools(`["lookup"]`, false),
} {
t.Run(name, func(t *testing.T) {
pkg, installed := fakeSidecarPackage(t, "fakeplugin", configure)
client, err := StartClient(context.Background(), ClientOptions{Package: pkg, Installed: installed, Session: testSessionContext()})
if err == nil {
_ = client.Close()
t.Fatal("the handshake was accepted")
}
if reason := protocolReason(t, err); reason != protocol.ErrCapabilityNotDeclared {
t.Fatalf("reason = %q, want %q", reason, protocol.ErrCapabilityNotDeclared)
}
})
}
}
+1
View File
@@ -407,6 +407,7 @@ func runtimePlanInfo(rt *pluginpkg.RuntimeSpec) *RuntimePlanInfo {
Intercepts: append([]string(nil), rt.Intercepts...),
Replaces: append([]string(nil), rt.Replaces...),
Capabilities: append([]string(nil), rt.Capabilities...),
Tools: rt.ToolNames(),
FullTrust: true,
}
}
+1
View File
@@ -140,6 +140,7 @@ type RuntimePlanInfo struct {
Intercepts []string `json:"intercepts,omitempty"`
Replaces []string `json:"replaces,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
Tools []string `json:"tools,omitempty"`
FullTrust bool `json:"fullTrust"`
}
+65 -1
View File
@@ -2,6 +2,7 @@ package pluginpkg
import (
"reasonix/internal/extensioncontract"
"regexp"
"bytes"
"encoding/json"
@@ -40,6 +41,30 @@ type RuntimeSpec struct {
// budget. Zero keeps the host's per-point defaults; the host clamps any
// value to its 60s ceiling at dispatch time.
TimeoutMillis int `json:"timeoutMillis,omitempty"`
// Tools are model-callable tools this runtime serves. They are declared
// here, not only at initialize, so an install plan can show them and the
// host knows their schemas before the process starts.
Tools []RuntimeTool `json:"tools,omitempty"`
}
// ToolNames lists the tools this runtime declares, in manifest order.
func (rt *RuntimeSpec) ToolNames() []string {
var out []string
for _, t := range rt.Tools {
out = append(out, t.Name)
}
return out
}
// RuntimeTool is one tool a runtime serves. The model sees it under a name the
// host qualifies with the plugin's; Name is how the runtime knows it.
type RuntimeTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"inputSchema"`
// ReadOnly is the author's claim that the tool changes nothing, which lets
// it run where only read-only tools may; it is never inferred.
ReadOnly bool `json:"readOnly,omitempty"`
}
// sniffManifestAPIVersion extracts just the apiVersion field so parseNative
@@ -332,7 +357,43 @@ var runtimeNamedSlots = map[string]bool{
"frontend_events": true,
}
var runtimeCapabilities = []string{"interceptors", "strategies", "providers", "ui"}
var runtimeCapabilities = []string{"interceptors", "strategies", "providers", "ui", "tools"}
var runtimeToolName = regexp.MustCompile(`^[A-Za-z0-9_-]{1,48}$`)
const maxRuntimeToolSchemaBytes = 64 << 10
// validateRuntimeTools holds each declared tool to what a provider accepts as
// a tool definition: a bounded name, a description the model can choose by,
// and an object schema for its arguments.
func validateRuntimeTools(rt *RuntimeSpec) error {
if len(rt.Tools) > 0 && !slices.Contains(rt.Capabilities, "tools") {
return errors.New(`runtime.tools needs "tools" in runtime.capabilities`)
}
seen := map[string]bool{}
for i, t := range rt.Tools {
if !runtimeToolName.MatchString(t.Name) {
return fmt.Errorf("runtime.tools[%d].name %q must be 1-48 letters, digits, _ or -", i, t.Name)
}
if seen[t.Name] {
return fmt.Errorf("runtime.tools: %q is declared twice", t.Name)
}
seen[t.Name] = true
if strings.TrimSpace(t.Description) == "" {
return fmt.Errorf("runtime.tools[%d] (%s): description is required", i, t.Name)
}
if len(t.InputSchema) > maxRuntimeToolSchemaBytes {
return fmt.Errorf("runtime.tools[%d] (%s): inputSchema is over %d bytes", i, t.Name, maxRuntimeToolSchemaBytes)
}
var schema struct {
Type string `json:"type"`
}
if err := json.Unmarshal(t.InputSchema, &schema); err != nil || schema.Type != "object" {
return fmt.Errorf(`runtime.tools[%d] (%s): inputSchema must be a JSON schema with "type": "object"`, i, t.Name)
}
}
return nil
}
// validateRuntimeSlot mirrors extension.ParseSlot: bare names must be
// declared slots; tool:/provider: forms must carry a well-formed target.
@@ -408,6 +469,9 @@ func parseV1Runtime(raw json.RawMessage) (*RuntimeSpec, error) {
return nil, fmt.Errorf("runtime.capabilities: unknown capability %q (want one of: %s)", capability, strings.Join(runtimeCapabilities, ", "))
}
}
if err := validateRuntimeTools(&rt); err != nil {
return nil, err
}
return &rt, nil
}
+21 -1
View File
@@ -357,7 +357,13 @@ func TestManifestV2RuntimeValidation(t *testing.T) {
{"unknown replace slot", `{"command": "./run", "replaces": ["systemprompt"]}`, `runtime.replaces: unknown slot "systemprompt"`},
{"bad tool slot", `{"command": "./run", "replaces": ["tool:"]}`, `runtime.replaces: invalid tool slot "tool:"`},
{"bad provider slot", `{"command": "./run", "replaces": ["provider:openai"]}`, `runtime.replaces: invalid provider slot "provider:openai"`},
{"unknown capability", `{"command": "./run", "capabilities": ["filesystem"]}`, `runtime.capabilities: unknown capability "filesystem" (want one of: interceptors, strategies, providers, ui)`},
{"unknown capability", `{"command": "./run", "capabilities": ["filesystem"]}`, `runtime.capabilities: unknown capability "filesystem" (want one of: interceptors, strategies, providers, ui, tools)`},
{"tools without capability", `{"command": "./run", "tools": [{"name": "t", "description": "d", "inputSchema": {"type": "object"}}]}`, `runtime.tools needs "tools" in runtime.capabilities`},
{"bad tool name", `{"command": "./run", "capabilities": ["tools"], "tools": [{"name": "a b", "description": "d", "inputSchema": {"type": "object"}}]}`, `runtime.tools[0].name "a b"`},
{"duplicate tool", `{"command": "./run", "capabilities": ["tools"], "tools": [{"name": "t", "description": "d", "inputSchema": {"type": "object"}}, {"name": "t", "description": "d", "inputSchema": {"type": "object"}}]}`, `runtime.tools: "t" is declared twice`},
{"tool without description", `{"command": "./run", "capabilities": ["tools"], "tools": [{"name": "t", "description": " ", "inputSchema": {"type": "object"}}]}`, `description is required`},
{"tool schema not an object", `{"command": "./run", "capabilities": ["tools"], "tools": [{"name": "t", "description": "d", "inputSchema": {"type": "string"}}]}`, `inputSchema must be a JSON schema with "type": "object"`},
{"tool unknown field", `{"command": "./run", "capabilities": ["tools"], "tools": [{"name": "t", "description": "d", "inputSchema": {"type": "object"}, "exec": "rm"}]}`, `exec`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -543,3 +549,17 @@ func TestExpandRuntimeCommand(t *testing.T) {
t.Fatalf("ExpandRuntimeCommand bare name = %q", got)
}
}
func TestManifestV2RuntimeDeclaresTools(t *testing.T) {
root := testenv.TempDir(t)
writeV2Plugin(t, root, `{"apiVersion": "reasonix.io/plugin/v2", "name": "rt-tools", "runtime": {"command": "./run", "capabilities": ["tools"], "tools": [
{"name": "lookup", "description": "Look a term up", "inputSchema": {"type": "object", "properties": {"q": {"type": "string"}}}, "readOnly": true}]}}`)
pkg, _, err := ParseDir(root)
if err != nil {
t.Fatal(err)
}
tools := pkg.Manifest.Runtime.Tools
if len(tools) != 1 || tools[0].Name != "lookup" || !tools[0].ReadOnly || !strings.Contains(string(tools[0].InputSchema), `"q"`) {
t.Fatalf("tools = %+v", tools)
}
}
+2 -1
View File
@@ -86,6 +86,7 @@ type pluginRuntime struct {
Intercepts []string `json:"intercepts,omitempty"`
Replaces []string `json:"replaces,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
Tools []string `json:"tools,omitempty"`
}
func (s *Server) plugins(w http.ResponseWriter, r *http.Request) {
@@ -159,7 +160,7 @@ func pluginViewFor(home, workspaceRoot string, p pluginpkg.InstalledPlugin) plug
if rt := pkg.Manifest.Runtime; rt != nil {
view.Runtime = &pluginRuntime{
Command: rt.Command, Args: rt.Args, Intercepts: rt.Intercepts,
Replaces: rt.Replaces, Capabilities: rt.Capabilities,
Replaces: rt.Replaces, Capabilities: rt.Capabilities, Tools: rt.ToolNames(),
}
}
view.Warnings = append(view.Warnings, hookRuntimeWarnings(pkg, workspaceRoot)...)
+10 -6
View File
@@ -57,12 +57,16 @@ func main() {
}
```
Everything else is optional and declared through `Options`: an `Observer`
for fire-and-forget events, a `Provider` for extension-hosted model
providers, `UI` callbacks plus the `HostUI` client for structured surfaces
(status, cards, forms, notifications and blocking prompts — never HTML/JS),
`ReadContentRef`/`ResolveExternalized` for large externalized payloads, and a
`Shutdown` hook.
Everything else is optional and declared through `Options`:
- `Observer` for fire-and-forget events;
- `Tools` for model-callable tools the manifest declares under
`runtime.tools` — the handshake names them for you;
- `Provider` for extension-hosted model providers;
- `UI` callbacks plus the `HostUI` client for structured surfaces (status,
cards, forms, notifications and blocking prompts — never HTML/JS);
- `ReadContentRef`/`ResolveExternalized` for large externalized payloads;
- `Shutdown`, run when the host asks the process to stop.
## Concurrency contract
+5 -30
View File
@@ -149,6 +149,9 @@ type Options struct {
Provider Provider
// UI serves extension/ui/action and extension/ui/submit.
UI UIHandler
// Tools serves extension/tool/call, keyed by the tool names the manifest
// declares under runtime.tools.
Tools map[string]ToolFunc
// Shutdown runs on extension/shutdown, bounded by the host's
// TimeoutMillis. After it returns (or times out) the SDK answers
// {accepted:true} and closes the transport; the process should then
@@ -299,6 +302,7 @@ func Serve(ctx context.Context, h Handler, opts Options) error {
c.reqH[MethodExtensionProviderStreamCancel] = s.withConnRequest(s.handleStreamCancel)
c.reqH[MethodExtensionUIAction] = s.withConnRequest(s.handleUIAction)
c.reqH[MethodExtensionUISubmit] = s.withConnRequest(s.handleUISubmit)
c.reqH[MethodExtensionToolCall] = s.withConnRequest(s.handleToolCall)
c.notH[MethodExtensionInitialized] = s.withConnNotification(s.handleInitialized)
c.notH[MethodExtensionEvent] = s.withConnNotification(s.handleEvent)
c.notH[MethodExtensionResourcesChanged] = s.withConnNotification(s.handleResourcesChanged)
@@ -418,6 +422,7 @@ func (s *server) handleInitialize(ctx context.Context, raw json.RawMessage) (any
if result.StateSchemaVersion < 0 {
return nil, &fatalError{err: errors.New("extension: stateSchemaVersion must be non-negative")}
}
s.declareTools(result)
return result, nil
}
@@ -711,36 +716,6 @@ func (s *server) sendStreamEnd(end *StreamEndParams) {
// UI handlers (Host → Extension)
func (s *server) handleUIAction(ctx context.Context, raw json.RawMessage) (any, error) {
if s.opts.UI.Action == nil {
return nil, MustProtocolError(ErrUnknownMethod)
}
var p UIActionParams
if err := strictDecode(raw, &p); err != nil || strings.TrimSpace(p.ActionID) == "" || strings.TrimSpace(p.SessionID) == "" {
return nil, MustProtocolError(ErrInvalidParams)
}
if err := s.opts.UI.Action(ctx, p.ActionID, p.Args); err != nil {
return UIActionResult{Accepted: false, Message: err.Error()}, nil
}
return UIActionResult{Accepted: true}, nil
}
func (s *server) handleUISubmit(ctx context.Context, raw json.RawMessage) (any, error) {
if s.opts.UI.Submit == nil {
return nil, MustProtocolError(ErrUnknownMethod)
}
var p UISubmitParams
if err := strictDecode(raw, &p); err != nil || strings.TrimSpace(p.SurfaceID) == "" ||
strings.TrimSpace(p.SessionID) == "" || p.Values == nil {
return nil, MustProtocolError(ErrInvalidParams)
}
if err := s.opts.UI.Submit(ctx, p.SurfaceID, p.Values); err != nil {
s.log.Printf("extension: UI submit for surface %q failed: %v", p.SurfaceID, err)
return UISubmitResult{Accepted: false}, nil
}
return UISubmitResult{Accepted: true}, nil
}
// HostUI: Extension → Host UI client
// HostUI is the sidecar's client for the host's structured UI surfaces. The
+47
View File
@@ -0,0 +1,47 @@
package extension
import (
"context"
"encoding/json"
"slices"
"strings"
"time"
)
// ToolFunc runs one call of a tool the extension serves. args is the model's
// arguments as raw JSON. A returned error reaches the model as the tool's own
// failure, which it can act on, rather than as a broken call.
type ToolFunc func(ctx context.Context, args json.RawMessage) (string, error)
// declareTools fills the handshake with the tools Options.Tools serves when
// the Handler named none itself.
func (s *server) declareTools(result *InitializeResult) {
if len(result.Tools) > 0 || len(s.opts.Tools) == 0 {
return
}
for name := range s.opts.Tools {
result.Tools = append(result.Tools, name)
}
slices.Sort(result.Tools)
}
func (s *server) handleToolCall(ctx context.Context, raw json.RawMessage) (any, error) {
var p ToolCallParams
if err := strictDecode(raw, &p); err != nil || strings.TrimSpace(p.Name) == "" || p.TimeoutMillis < 0 {
return nil, MustProtocolError(ErrInvalidParams)
}
run, ok := s.opts.Tools[p.Name]
if !ok || run == nil {
return nil, MustProtocolError(ErrUnknownMethod)
}
if p.TimeoutMillis > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(p.TimeoutMillis)*time.Millisecond)
defer cancel()
}
content, err := run(ctx, p.Arguments)
if err != nil {
return ToolCallResult{Content: err.Error(), IsError: true}, nil
}
return ToolCallResult{Content: content}, nil
}
+41
View File
@@ -0,0 +1,41 @@
package extension
import (
"context"
"encoding/json"
"errors"
"testing"
)
// A tool the extension serves answers a call with its content, reports its
// own failure as isError, and names itself in the handshake without the
// Handler having to repeat Options.Tools.
func TestToolsAreDeclaredAndServed(t *testing.T) {
tools := map[string]ToolFunc{
"lookup": func(_ context.Context, args json.RawMessage) (string, error) {
return "found " + string(args), nil
},
"broken": func(context.Context, json.RawMessage) (string, error) {
return "", errors.New("the index is not built yet")
},
}
host, _ := startFakeHost(t, basicHandler(), Options{Tools: tools})
init := host.handshake(t)
if len(init.Tools) != 2 || init.Tools[0] != "broken" || init.Tools[1] != "lookup" {
t.Fatalf("handshake tools = %v, want both, sorted", init.Tools)
}
var out ToolCallResult
resp := host.request(MethodExtensionToolCall, ToolCallParams{Name: "lookup", Arguments: json.RawMessage(`{"q":"x"}`), TimeoutMillis: 1000})
if err := json.Unmarshal(resp.Result, &out); err != nil || out.IsError || out.Content != `found {"q":"x"}` {
t.Fatalf("lookup = %+v err=%v respErr=%+v", out, err, resp.Err)
}
resp = host.request(MethodExtensionToolCall, ToolCallParams{Name: "broken", Arguments: json.RawMessage(`{}`)})
if err := json.Unmarshal(resp.Result, &out); err != nil || !out.IsError || out.Content != "the index is not built yet" {
t.Fatalf("broken = %+v err=%v", out, err)
}
resp = host.request(MethodExtensionToolCall, ToolCallParams{Name: "absent", Arguments: json.RawMessage(`{}`)})
if resp.Err == nil || resp.Err.Code != CodeMethodNotFound {
t.Fatalf("an unserved tool = %+v, want unknown_method", resp.Err)
}
}
+16
View File
@@ -45,6 +45,7 @@ const (
MethodExtensionProviderStreamOpen = "extension/provider/stream/open"
MethodExtensionResourcesChanged = "extension/resources/changed"
MethodExtensionShutdown = "extension/shutdown"
MethodExtensionToolCall = "extension/tool/call"
MethodExtensionUIAction = "extension/ui/action"
MethodExtensionUISubmit = "extension/ui/submit"
MethodHostContentRead = "host/content/read"
@@ -143,6 +144,7 @@ type ManifestExpectation struct {
Replaces []string `json:"replaces,omitempty"`
Providers []string `json:"providers,omitempty"`
UIActions []string `json:"uiActions,omitempty"`
Tools []string `json:"tools,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
Requires []RequirementWire `json:"requires,omitempty"`
Provides []CapabilityWire `json:"provides,omitempty"`
@@ -204,6 +206,7 @@ type InitializeResult struct {
Replaces []string `json:"replaces,omitempty"`
Providers []ProviderDescriptor `json:"providers,omitempty"`
UIActions []UIActionDecl `json:"uiActions,omitempty"`
Tools []string `json:"tools,omitempty"`
Requires []RequirementWire `json:"requires,omitempty"`
Provides []CapabilityWire `json:"provides,omitempty"`
StateSchemaVersion int `json:"stateSchemaVersion" validate:"min=0"`
@@ -436,6 +439,19 @@ type ShutdownResult struct {
Accepted bool `json:"accepted"`
}
// ToolCallParams is a generated Extension Protocol v2 wire DTO.
type ToolCallParams struct {
Name string `json:"name" validate:"nonempty"`
Arguments json.RawMessage `json:"arguments"`
TimeoutMillis int `json:"timeoutMillis" validate:"min=0"`
}
// ToolCallResult is a generated Extension Protocol v2 wire DTO.
type ToolCallResult struct {
Content string `json:"content"`
IsError bool `json:"isError,omitempty"`
}
// UIActionParams is a generated Extension Protocol v2 wire DTO.
type UIActionParams struct {
ActionID string `json:"actionId" validate:"nonempty"`
+37
View File
@@ -0,0 +1,37 @@
package extension
import (
"context"
"encoding/json"
"strings"
)
func (s *server) handleUIAction(ctx context.Context, raw json.RawMessage) (any, error) {
if s.opts.UI.Action == nil {
return nil, MustProtocolError(ErrUnknownMethod)
}
var p UIActionParams
if err := strictDecode(raw, &p); err != nil || strings.TrimSpace(p.ActionID) == "" || strings.TrimSpace(p.SessionID) == "" {
return nil, MustProtocolError(ErrInvalidParams)
}
if err := s.opts.UI.Action(ctx, p.ActionID, p.Args); err != nil {
return UIActionResult{Accepted: false, Message: err.Error()}, nil
}
return UIActionResult{Accepted: true}, nil
}
func (s *server) handleUISubmit(ctx context.Context, raw json.RawMessage) (any, error) {
if s.opts.UI.Submit == nil {
return nil, MustProtocolError(ErrUnknownMethod)
}
var p UISubmitParams
if err := strictDecode(raw, &p); err != nil || strings.TrimSpace(p.SurfaceID) == "" ||
strings.TrimSpace(p.SessionID) == "" || p.Values == nil {
return nil, MustProtocolError(ErrInvalidParams)
}
if err := s.opts.UI.Submit(ctx, p.SurfaceID, p.Values); err != nil {
s.log.Printf("extension: UI submit for surface %q failed: %v", p.SurfaceID, err)
return UISubmitResult{Accepted: false}, nil
}
return UISubmitResult{Accepted: true}, nil
}
+7 -7
View File
@@ -8,13 +8,13 @@
"complexity": 1009,
"doc-language": 0,
"doc-owner": 43,
"doc-prose": 2325,
"doc-prose": 2324,
"error-text": 9,
"essay": 2907,
"file-size": 24032,
"file-size": 23965,
"flat-view": 0,
"frontend-parity": 32,
"function-size": 4350,
"function-size": 4348,
"layering": 0,
"marker": 0,
"narrative": 0,
@@ -480,8 +480,8 @@
"internal/boot/boot.go": {
"complexity": 125,
"essay": 76,
"file-size": 939,
"function-size": 1015
"file-size": 897,
"function-size": 1013
},
"internal/boot/boot_test.go": {
"essay": 10
@@ -1654,7 +1654,7 @@
},
"sdk/go/README.md": {
"doc-owner": 1,
"doc-prose": 11
"doc-prose": 10
},
"sdk/go/examples/fullsidecar/main.go": {
"essay": 25
@@ -1664,7 +1664,7 @@
},
"sdk/go/sdk.go": {
"essay": 27,
"file-size": 367
"file-size": 342
},
"sdk/go/types_ext.go": {
"essay": 7