feat(composition): load preset resources on demand

Problem: selecting a custom preset captures unused skill templates and
extension packages and waits for optional MCP startup.

Root cause: resource snapshot creation also served as the runtime permission
model, making selection cost scale with installed resource payload.

Fix: add live capability-grant bindings while preserving legacy snapshots.
Resolve current skill/MCP/extension/hook/command authority at use, defer
optional startup, retain stable provider contracts, and fence new event
and archive formats from older readers. Use lightweight status summaries
and per-instance extension call leases for safe concurrent replacement.

Verification: root and Desktop suites, full boot/builtin suites, affected
storage/controller/frontend tests, lifecycle race checks, frontend interaction
tests and typecheck, lint/vet/check, actionlint, and Windows cross-compilation.
Twenty-sample real switch measurements include build, persist and publication;
64 MiB unused templates remain outside the binding and stay below the local
500 ms P95 target. Native Windows execution is delegated to hosted CI.
This commit is contained in:
SivanCola committed 2026-10-20 18:49:01 +08:00
1 parent 049a8c42e1
commit 073079d675
90 files changed
+2759 -433

No files matched your search

+2 -2
View File
@@ -185,11 +185,11 @@ jobs:
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(CaptureDirectoryMaterializesContainedLinks|CaptureDirectoryRefusesUnsafeLinks|CaptureHookFileFlattensContainedLink|MaterializeStillRejectsLinkedRetainedFilesAndRoot|WindowsCompositionJunctions|CompositionRetainsLinkedSkillAcrossRestart|CompositionLinkedSkillRejectsLinkedResources|CompositionLinkedExtensionResources|CompositionLinkedHookResources)$' ./internal/state/agentpreset ./internal/assembly/boot | tee "$RUNNER_TEMP/composition-links.jsonl"
go test -count=1 -timeout=3m -json -run '^Test(CaptureDirectoryMaterializesContainedLinks|CaptureDirectoryRefusesUnsafeLinks|CaptureHookFileFlattensContainedLink|MaterializeStillRejectsLinkedRetainedFilesAndRoot|WindowsCompositionJunctions|WindowsLiveSkillJunctions|CompositionRetainsLinkedSkillAcrossRestart|CompositionLinkedSkillRejectsLinkedResources|CompositionLinkedExtensionResources|CompositionLinkedHookResources)$' ./internal/state/agentpreset ./internal/ext/skill ./internal/assembly/boot | tee "$RUNNER_TEMP/composition-links.jsonl"
jq -se '
["TestCaptureDirectoryMaterializesContainedLinks", "TestCaptureDirectoryRefusesUnsafeLinks",
"TestCaptureHookFileFlattensContainedLink", "TestMaterializeStillRejectsLinkedRetainedFilesAndRoot",
"TestWindowsCompositionJunctions", "TestCompositionRetainsLinkedSkillAcrossRestart",
"TestWindowsCompositionJunctions", "TestWindowsLiveSkillJunctions", "TestCompositionRetainsLinkedSkillAcrossRestart",
"TestCompositionLinkedSkillRejectsLinkedResources", "TestCompositionLinkedExtensionResources",
"TestCompositionLinkedHookResources"] as $required |
[.[] | select(.Action == "pass") | .Test] as $passed |
@@ -110,6 +110,9 @@ export const EN_COMPOSITION: Record<string,string> = {
"注入运行环境与离线提示": "Include runtime context and offline hints",
"选择方式": "Selection mode",
"创建会话时继承当前配置": "Inherit current configuration when creating the conversation",
"跟随当前配置,包含之后启用的资源": "Follow current configuration, including resources enabled later",
"预设决定允许使用哪些能力,实际使用时按需加载当前资源。已开始会话的人设和权限规则保持不变。": "Presets define which capabilities are allowed. Current resources load when needed. Started conversations keep their persona and permission rules.",
"已固定资源快照": "Resources fixed to a saved snapshot",
"仅使用所选项(空列表表示禁用)": "Use selected modules only (empty disables them)",
"独立参数(JSON);env 和 headers 仅填写 ${NAME} 引用": "Per-composition parameters (JSON); use only ${NAME} references in env and headers",
"独立参数": "Parameters",
@@ -27,6 +27,6 @@ export interface AgentPresetCatalog {
modules?: Partial<Record<CompositionKind,CompositionChoice[]>>;
}
export interface AgentPresetImport {definition:AgentComposition;diagnostics:AgentPresetCatalog["diagnostics"]}
export interface CompositionSummary { id: string; name: string; revision: string; digest: string; started: boolean; unavailable?: string }
export interface CompositionSummary { id: string; name: string; revision: string; digest: string; started: boolean; unavailable?: string; formatVersion?: number; resourceMode?: "live" }
export const COMPOSITION_KINDS: CompositionKind[] = ["tools", "skills", "mcp", "extensions", "hooks", "commands"];
export const builtinCompositions = (): AgentComposition[] => structuredClone(definitions) as AgentComposition[];
@@ -30,10 +30,10 @@ export class MockCompositions {
private get catalog():AgentPresetCatalog {
return {presets:[...builtinCompositions().map((definition)=>({definition,revision:revisions[definition.id as keyof typeof revisions]})),...this.library.user,...(this.library.projects.get(this.workspace) ?? [])],default:this.library.workspaceDefaults.get(this.workspace) ?? this.library.personalDefault,diagnostics:[],modules:MOCK_MODULES};
}
private summary(v:AgentPresetVersion):CompositionSummary { return {id:v.definition.id,name:v.definition.name,revision:v.revision,digest:`mock-${v.revision}`,started:false}; }
private summary(v:AgentPresetVersion):CompositionSummary { return {id:v.definition.id,name:v.definition.name,revision:v.revision,digest:`mock-${v.revision}`,started:false,formatVersion:2,resourceMode:"live"}; }
private fail(status:number,code:string):never { throw new HttpError(status,"",{code:`agent_composition.${code}`}); }
private requireModules(definition:AgentComposition) {
for (const kind of ["tools","skills","mcp","extensions","hooks","commands"] as const) for (const member of definition[kind]?.members ?? []) {
for (const kind of ["tools"] as const) for (const member of definition[kind]?.members ?? []) {
if (definition[kind]?.mode==="select" && !(MOCK_MODULES?.[kind] ?? []).some((m)=>m.id===member.id)) {
throw new HttpError(409,"",{code:"agent_composition.unavailable",params:{module:`${kind}:${member.id}`,detail:"Required module is unavailable or was revoked."}});
}
@@ -11,6 +11,17 @@ import {RunLine} from "./RunLine";
beforeEach(() => { localStorage.setItem("rx-show-agent-composition", "on"); });
afterEach(() => { cleanup(); localStorage.removeItem("rx-show-agent-composition"); });
it.each([false, true])("labels saved snapshots without labeling live bindings: live=%s", async (live) => {
const port = new MockPort();
vi.spyOn(port, "agentComposition").mockResolvedValue({
id: "builtin:minimal", name: "轻装搭档", revision: "fixture", digest: "fixture", started: true,
...(live ? {formatVersion: 2, resourceMode: "live" as const} : {}),
});
render(<AgentCompositionPicker port={port} locked running={false} onChanged={()=>{}} onSettings={()=>{}} onError={()=>{}}/>);
await userEvent.click(await screen.findByRole("button", {name: /^Agent 组合.*轻装搭档/}));
expect(screen.queryByText("已固定资源快照") !== null).toBe(!live);
});
it.each([null, "off", "unexpected"])("hides the picker and avoids catalog reads for visibility %s", async (stored) => {
if (stored === null) localStorage.removeItem("rx-show-agent-composition");
else localStorage.setItem("rx-show-agent-composition", stored);
@@ -76,7 +76,7 @@ export function AgentCompositionPicker({ port, binding, locked: statusLocked, ru
pending={busy}
label={<><span className="nm">{label}</span><StudioIcon name="down" /></>}
menuTitle={<><b>{t("Agent 预设")}</b><small>{t(locked ? "已锁定" : "首次提交前可切换")}</small></>}
head={locked && <p className="composition-menu-note">{t("该会话已开始,模式已锁定。请用其他模式新建会话。")}</p>}
head={<>{locked && <p className="composition-menu-note">{t("该会话已开始,模式已锁定。请用其他模式新建会话。")}</p>}{current && current.resourceMode !== "live" && <p className="composition-menu-note">{t("已固定资源快照")}</p>}</>}
items={items}
current={locked ? undefined : current?.id ?? "builtin:general"}
onPick={choose}
@@ -19,7 +19,7 @@ export function CompositionCatalog({ catalog, onSelect, onCreate, onCopy }: {
<header className="composition-list-heading">
<h2>{t("Agent 预设")}</h2>
<button className="btn" data-primary="" data-action="agent-preset.create" onClick={onCreate}><StudioIcon name="plus" />{t("新建预设")}</button>
<p>{t("为不同会话组合人设、工具、技能与扩展。模型保持独立;已开始的会话保留原版本。")}</p>
<p>{t("预设决定允许使用哪些能力,实际使用时按需加载当前资源。已开始会话的人设和权限规则保持不变。")}</p>
</header>
<CompositionPickerSetting />
<p className="composition-default-note">{t("点击卡片,设置本机新任务的默认模式;已有会话保持当前配置。")}</p>
@@ -191,7 +191,7 @@ function ModuleEditor({
onChange({ mode: e.target.value as "inherit" | "select", members: [] });
}}
>
<option value="inherit">{t("创建会话时继承当前配置")}</option>
<option value="inherit">{t("跟随当前配置,包含之后启用的资源")}</option>
<option value="select">{t("仅使用所选项(空列表表示禁用)")}</option>
</select>
{value.mode === "select" && (
+65 -15
View File
@@ -2,7 +2,7 @@
owner: "@SivanCola"
backup: "@esengine"
status: active
reviewed: 2026-10-18
reviewed: 2026-10-20
---
# Session Agent compositions
@@ -92,9 +92,11 @@ Code review and Research are no longer built-in catalog entries. Their existing
conversations retain the original binding. An old default pointing to a retired
entry reports a diagnostic until an available default is explicitly chosen.
Copy a built-in to customize it. Each module group either inherits configuration
when a conversation binds, or uses only the selected IDs. An empty explicit
selection disables the group. The editor lists locally available modules;
Copy a built-in to customize it. Each module group either follows current authorized
configuration, including resources enabled later, or uses only the selected IDs. An empty explicit
selection disables the group.
The editor lists locally available modules;
imports preview missing dependencies without installing or launching them.
Settings lists the page as Persona. The editor shows name, purpose and both
@@ -110,7 +112,10 @@ Everyday partner's user-config persona has a separate editor, accessible from Ev
details or Settings search.
Saving creates a definition revision. Existing conversations retain their
resolved persona, parameters and resources. The page displays the definition
resolved persona, parameters and permission rules. New conversations load current resources
on demand; existing snapshot conversations keep their saved resources.
The page displays the definition
revision beside the conversation's bound revision and reports stale-save
conflicts while preserving the draft. Models remain independently selectable.
@@ -205,12 +210,42 @@ readable as history but cannot execute, even with an old local authorization
record. It is not silently rewritten. Valid timeout-only project bindings
retain their original version.
Inherited choices resolve once at binding. Retained skill and non-blocking hook files and
extension code do not follow subsequent source edits.
### On-demand conversations and existing snapshots
New bindings use `formatVersion: 2` and `resourceMode: "live"`. Selecting a preset
records permission rules, not resource file contents.
Skill discovery does not
capture template directories or complete every skill. Reading or invoking one
skill loads its body and the same references as an ordinary conversation.
Commands and non-blocking hooks use current authorized sources when invoked.
MCP directory reads do not connect an uncached server. An explicit capability
call connects after authorization; configured automatic connections remain
independent of selection. MCP and extension tools use the stable
`use_capability` entry, without changing the provider-visible schema.
Tool-only extension runtimes start on demand from their installed source, without
making a private package copy.
Providers, required runtimes and interceptors
initialize at the lifecycle stage that needs them. Changes to those startup
contracts require a new session or an explicit runtime reload; an existing
conversation never silently changes its system prompt.
Binding the preset does not approve a resource. Missing optional skills, MCP
servers and tool-only extensions do not prevent ordinary conversation; use of
that capability reports its failure. Revocation blocks subsequent calls.
Safety guards remain mandatory.
Bindings without the new fields retain snapshot behavior. Their inherited choices
resolved once at binding, and retained skill, non-blocking hook and extension
files do not follow subsequent source edits. They are not automatically converted.
Hooks retain referenced files and sibling bundles for common script formats.
Bundles must fit the resource limit and cannot contain credential files or
symbolic links.
escaping or cyclic symbolic links. Contained source links are captured as regular
files; saved snapshot files must remain regular files.
Keep hook code in a dedicated directory; retain credentials
through environment references. External interpreters and services keep their
@@ -221,7 +256,7 @@ file paths while rejecting literal credential arguments and unparseable shell la
Credential lookups remain
with the existing credential/configuration owner. Sessions use separate MCP
connections and process state, and extensions run from private copies. Existing
connections and process state. Existing snapshot extensions run from private copies. Existing
trust, revocation, authorization and sandbox checks still apply.
### Safety policy stays outside the composition
@@ -247,27 +282,42 @@ An authorized policy edit may change a guard; selecting a composition cannot.
Project program approval and package activation still control which guards load.
A binding is local transcript metadata and never enters provider messages.
Only explicitly bound composition transcripts use native event-log schema 4; older readers refuse
them instead of continuing with another tool set. Legacy transcripts keep
Snapshot compositions use native event-log schema 4; live compositions use schema 5.
Older readers refuse unsupported schemas instead of continuing with another tool set,
falling back to a stale checkpoint or rewriting the newer log.
Legacy transcripts keep
their compatibility path and are not assigned today's default on restore.
Untrusted or unavailable bindings can be opened for history without launching
modules; continuation is blocked. Deleting a definition does not remove its
conversation's retained binding.
Native JSON exports use the `reasonix-native-session` version 2 envelope and
include the authoritative event-log binding and retained resources. Import
Snapshot JSON exports use the `reasonix-native-session` version 2 envelope and
include the authoritative event-log binding and retained resources.
Live exports
use version 3 and contain history and permission rules, without local resource
files or credentials.
The current reader accepts both formats. Older clients
cannot continue a live-format conversation. Import
them under Settings → Storage. Import never creates local authorization records
or starts programs.
Configuration backup version 3 also includes personal
preset definitions and their default selection.
MCP tool contracts are pinned at binding. A changed tool catalog after refresh
or reconnect blocks execution instead of silently replacing schemas. Package
For snapshot conversations, MCP tool contracts are pinned at binding. A changed tool catalog after refresh
or reconnect blocks execution instead of silently replacing schemas.
Package
MCP processes and extension sidecars use private package copies; shutdown and
failed assembly release their private directories.
Live conversations instead
validate current authorization and server identity at dispatch.
ACP composition changes preserve the session's vetted additional directories.
A dispatched session inherits the exact binding instead of selecting today's
+41 -13
View File
@@ -2,7 +2,7 @@
owner: "@SivanCola"
backup: "@esengine"
status: active
reviewed: 2026-10-18
reviewed: 2026-10-20
---
# 会话级 Agent 预设
@@ -64,7 +64,7 @@ Reasonix 已使用相同的 minimal 开场提示。
代码审查、资料研究不再作为内置目录项提供;已有会话继续保留原绑定版本。
如果旧默认指向已移除的内置项,目录会提示用户明确选择新的默认项,不自动替换。
内置项只读,复制后才能修改。每类模块支持「绑定时继承当前配置」或
内置项只读,复制后才能修改。每类模块支持「跟随当前配置,包含之后启用的资源」或
「仅使用所选项」;显式空列表表示禁用。编辑器提供本机模块目录,导入先预览
缺失依赖,不会安装依赖或启动程序。
@@ -76,7 +76,8 @@ Reasonix 已使用相同的 minimal 开场提示。
保存失败继续保留草稿。日常搭档的用户人设使用独立编辑入口,可从日常搭档详情
或设置搜索进入,与自定义预设分开保存。
保存产生新的定义版本,已存在的会话继续使用原先解析的人设、参数和资源。
保存产生新的定义版本,已存在的会话继续使用原先解析的人设、参数和权限规则。
按需会话使用当前已授权资源,旧快照会话继续使用保留资源。
页面分别显示定义版本和当前会话绑定版本。并发保存冲突时保留草稿并提示重载。
## 存储与兼容
@@ -151,12 +152,33 @@ Hook 的项目程序授权与撤销检查继续生效;选择预设不等于批
此前保存的项目绑定若含启动参数覆盖,历史仍可读取,但不能继续执行,即使本机
保留了旧授权记录;不会静默改写为其他组合。合法的仅超时项目绑定继续保留原版本。
继承项只在绑定时解析一次。技能、非阻断型 Hook 和扩展代码使用保留副本,后续源文件
修改不会改写旧版本。凭据继续由原有凭据或配置机制提供。MCP 连接与进程状态
按会话隔离,扩展使用独立运行副本。原有信任、撤销、授权和沙箱规则继续生效。
### 按需会话与已有快照
新绑定使用 `formatVersion: 2`、`resourceMode: "live"`,保存权限规则,不保存资源文件内容。
切换时不捕获技能模板目录,也不加载全部技能的补充资料。读取或调用某个技能时,
才按普通会话的规则加载其正文和 references。命令与非阻断型 Hook 在使用时读取当前已授权资源。
继承模式跟随当前配置;明确勾选的模式保持白名单。两者都不能绕过资源启用状态和授权。
缺失的可选技能、离线 MCP 或纯工具扩展不阻止普通聊天,在实际使用该能力时报告问题。
撤销授权后,后续调用被拒绝;安全 Hook 仍然必须执行。
MCP 目录读取不连接未缓存的服务器,实际能力调用通过授权检查后才连接。
显式配置的自动连接独立于切换过程。MCP 与扩展工具使用稳定的 `use_capability` 入口,
不因连接状态改变模型可见的工具定义。
纯工具扩展按需从安装来源启动,不复制整个包。模型提供方、必需运行时及拦截器在对应
生命周期阶段初始化;改变这些启动契约后,需要新建会话或明确重载运行时,不能静默改写
已开始会话的系统提示。凭据仍由原凭据或配置机制提供,MCP 连接和进程状态按会话隔离。
没有新字段的已有绑定继续保留快照语义:继承项在绑定时解析一次,技能、非阻断型 Hook
和扩展代码使用保留副本,不跟随后续源文件修改。旧会话不自动转换。
原有信任、撤销、授权和沙箱规则继续生效。
Hook 保留直接引用的文件,并为常见脚本格式保留同目录依赖。脚本目录受资源
大小限制,不能包含凭据文件或符号链接;建议将代码放在独立目录,凭据使用
大小限制,不能包含凭据文件或越界、循环链接;目录内的合法源链接保存为普通文件,
已保存的快照不得被替换为链接。
建议将代码放在独立目录,凭据使用
环境引用。外部解释器和服务继续遵守原有平台及授权契约。
保留时按参数边界检查凭据,正确识别带引号的 Windows 文件路径;字面凭据参数和
无法解析的 Shell 启动命令仍被拒绝。
@@ -178,13 +200,18 @@ Hook 保留直接引用的文件,并为常见脚本格式保留同目录依赖
在设置中保存安全 Hook 会立即更新会话和子角色共享的运行器,不替换保留的自动化,
也不改写组合摘要。用户有权编辑安全策略,选择组合不能移除它。
绑定只进入本地会话元数据,不发送给 provider。组合会话使用原生事件日志
schema 4,旧程序会明确拒绝。无绑定历史仍走兼容路径,不套用当前默认组合。
绑定只进入本地会话元数据,不发送给 provider。快照会话使用原生事件日志 schema 4,
按需会话使用 schema 5。不支持该格式的旧程序明确拒绝,不回退到过期 checkpoint,
不截断或改写新日志。
无绑定历史仍走兼容路径,不套用当前默认组合。
无法授权或装配原绑定时,可以只读查看历史,不能继续执行。删除声明不会
删除已有会话所需的绑定版本。
原生导出使用 `reasonix-native-session` version 2 封装,包含权威日志中的绑定
和保留资源,可在「设置 → 存储」导入。导入不创建本机授权,不启动程序。
快照原生导出使用 `reasonix-native-session` version 2,包含绑定和保留资源。
按需会话导出使用 version 3,只包含历史与权限规则,不包含本地资源文件或凭据。
当前版本可读取两种格式;旧客户端不能继续执行新格式会话。可在「设置 → 存储」
导入;导入不创建本机授权,不启动程序。
前端直接上传原始文件,不在渲染器读取或解析 JSON。后端复用同一原生解码器和
192 MiB/回放记录数限制。
@@ -205,9 +232,10 @@ schema 4,旧程序会明确拒绝。无绑定历史仍走兼容路径,不套
配置备份 version 3 同时包含个人预设声明及个人默认选择。
MCP 在绑定时锁定工具契约。刷新或重连后若工具目录不兼容,会阻止继续执行,
旧快照的 MCP 在绑定时锁定工具契约。刷新或重连后若工具目录不兼容,会阻止继续执行,
不会静默替换 schema。扩展及包内 MCP 采用私有运行副本,关闭和装配失败时
释放对应临时目录。Hook 命令中的凭据必须改用环境引用。
释放对应临时目录。新按需会话在派发时检查当前授权和服务器身份。
Hook 命令中的凭据必须改用环境引用。
ACP 切换组合会保留本会话已校验的额外工作目录。派发新会话时继承精确绑定,
不重新选择当前默认组合;限定工作区的绑定不能派发到另一工作区,会在打开
+56 -103
View File
@@ -2,7 +2,6 @@ package boot
import (
"context"
"crypto/rand"
"errors"
"fmt"
"io"
@@ -15,6 +14,7 @@ import (
"reasonix/internal/base/netclient"
"reasonix/internal/base/netdns"
"reasonix/internal/base/secrets"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/pricing"
@@ -24,7 +24,6 @@ import (
"reasonix/internal/ext/extension"
"reasonix/internal/ext/extension/sidecar"
"reasonix/internal/ext/hook"
"reasonix/internal/ext/mcplaunch"
"reasonix/internal/ext/plugin"
"reasonix/internal/ext/pluginspec"
"reasonix/internal/model/billing"
@@ -44,33 +43,34 @@ import (
// builder carries one build's state from stage to stage. Each stage reads
// what the earlier ones settled and adds its own; none reaches back.
type builder struct {
ctx context.Context
closeLifetime context.CancelFunc
opts Options
timer *phaseTimer
owner *extension.RuntimeOwner
fileWriteReceipt func(path string, hadPrior bool, prior []byte)
stderr io.Writer
trace *otlp.Exporter
root string
repo gitcmd.Repo // root's git identity, settled in load
roots config.Roots
cfg *config.Config
sink event.Sink
quoteContext *event.QuoteContext
proxy netclient.ProxySpec
ext *extensionStage
pendingMgr *sidecar.Manager // sidecars no RuntimeSet owns yet
providers providerStage
model modelSelection
keep agent.KeepPolicy
session sessionRuntime
balanceClient *http.Client
keyedClient *http.Client // carries provider keys; refuses cross-origin redirects
execProv provider.Provider
shell sandbox.Shell
prompt promptAssembly
additionalDirs []string
compositionBinding func(agentcomposition.Version, string) *agentcomposition.Binding
ctx context.Context
closeLifetime context.CancelFunc
opts Options
timer *phaseTimer
owner *extension.RuntimeOwner
fileWriteReceipt func(path string, hadPrior bool, prior []byte)
stderr io.Writer
trace *otlp.Exporter
root string
repo gitcmd.Repo // root's git identity, settled in load
roots config.Roots
cfg *config.Config
sink event.Sink
quoteContext *event.QuoteContext
proxy netclient.ProxySpec
ext *extensionStage
pendingMgr *sidecar.Manager // sidecars no RuntimeSet owns yet
providers providerStage
model modelSelection
keep agent.KeepPolicy
session sessionRuntime
balanceClient *http.Client
keyedClient *http.Client // carries provider keys; refuses cross-origin redirects
execProv provider.Provider
shell sandbox.Shell
prompt promptAssembly
additionalDirs []string
tools toolStage
cleanup func()
@@ -115,7 +115,11 @@ type toolStage struct {
// objects it just assembled. The controller owns plugin subprocesses until
// Controller.Close releases them.
func build(ctx context.Context, opts Options) (*BuildResult, error) {
b := &builder{timer: newPhaseTimer()}
b := &builder{timer: newPhaseTimer(), compositionBinding: newLiveCompositionBinding}
return b.build(ctx, opts)
}
func (b *builder) build(ctx context.Context, opts Options) (*BuildResult, error) {
opts = observeOverrides(opts)
b.timer.observe = opts.OnPhase
// The runtime outlives the request that built it (Studio opens a pane with
@@ -431,68 +435,31 @@ func (b *builder) wireTools() error {
t.runners = skillRunners{readOnly: t.skillRun.runReadOnly, run: t.skillRun.run, profile: skillProfile(cfg, t.sub.inheritedFor)}
t.cmds = loadCommands(opts, root)
addInstallSourceTool(b.ctx, t.reg, t.host, root, b.balanceClient, t.specOptions, opts.Stderr)
registerSkillTools(t.reg, opts.Ablation, b.prompt.skillStore, b.prompt.implicitSkills, t.runners, t.cmds)
var liveCommands func() []command.Command
if opts.Composition.Live() {
liveCommands = func() []command.Command { return loadCommands(opts, root) }
}
registerSkillTools(t.reg, opts.Ablation, b.prompt.skillStore, b.prompt.implicitSkills, t.runners, t.cmds, liveCommands)
t.caps = newCapabilitySurface(b.ctx, cfg, root, t.specOptions, t.host, t.reg, b.prompt.skillStore, b.model.profile, t.mcp.enabled, t.mcp.extra...)
if opts.Composition.Live() {
t.caps.runtime.ConfigureComposition(opts.Composition.Resolved)
t.caps.runtime.ConfigureInventorySource(b.liveMCPInventory())
t.reg.BindMCPRuntime(t.caps.runtime.BindRegisteredTool)
if _, ok := t.reg.Get(plugin.ResourceReadToolName); ok {
t.reg.Add(t.caps.runtime.ResourceReadTool())
}
}
t.skillRun.capRuntime = t.caps.runtime
addExtensionTools(t.reg, b.ext.mgr, b.ext.warn)
if opts.Composition.Live() {
t.caps.refresh = func() { addExtensionTools(t.reg, b.ext.mgr, b.ext.warn) }
}
t.reg.Add(t.caps.proxy)
gateSkillsOnCatalog(b.prompt.skillStore, t.caps.catalog)
return nil
}
// wireMCP registers the MCP servers and LSP tools and chains their shutdown
// into the controller's cleanup. A caller-supplied shared host lets
// controllers for one workspace reuse running MCP processes; its owner closes it.
func (b *builder) wireMCP() error {
opts, cfg, root, t := b.opts, b.cfg, b.root, &b.tools
t.host = opts.SharedHost
if t.host == nil {
t.host = plugin.NewHost()
}
// A lazy server connects in the background; without a status sink every
// status view keeps showing what it saw at boot.
t.host.SetStatusSink(opts.Sink)
t.specOptions = pluginspec.Options{
DefaultStartupTimeout: time.Duration(cfg.MCPStartupTimeoutSeconds()) * time.Second,
DefaultCallTimeout: time.Duration(cfg.MCPCallTimeoutSeconds()) * time.Second,
LaunchManager: mcplaunch.ForWorkspace(b.roots.Home(), root),
ConfigSource: "workspace_config",
StateHome: b.roots.Home(),
WriterRoots: t.env.writeRoots,
WritableRoots: t.env.writeRoots,
ForbidReadRoots: t.env.forbidReadRoots,
Network: t.env.network,
PackageOwners: pluginspec.PackageOwners(cfg),
OAuthHTTPClient: b.balanceClient,
}
if opts.Composition != nil {
t.specOptions.ProcessScope = rand.Text()
}
t.mcp = resolveMCPSpecs(opts, cfg, root, t.specOptions)
b.cleanup = t.host.Close
if opts.SharedHost != nil {
b.cleanup = func() {}
}
if err := b.isolateCompositionMCPPackages(); err != nil {
return err
}
reportProjectMCPAwaitingApproval(b.sink, cfg, root)
t.configSpecs, t.mcpSchemaKnown = registerMCPTools(b.ctx, t.host, t.reg, t.mcp, b.sink)
registerResourceTool(t.host, t.reg, cfg, t.mcp)
// The LSP manager is session-scoped: its servers stop with the controller.
if t.lsp = registerLSP(t.reg, cfg, root); t.lsp != nil {
prev, mgr := b.cleanup, t.lsp
b.cleanup = func() { prev(); mgr.Close() }
}
if proxy := t.env.egress; proxy != nil {
prev := b.cleanup
b.cleanup = func() { prev(); _ = proxy.Close() }
}
b.cleanup = closeAfter(b.cleanup, b.trace)
return nil
}
// controller builds the executor, the optional planner around it, and the
// controller that drives them, then binds what could only exist after it.
func (b *builder) controller() (*control.Controller, error) {
@@ -529,12 +496,16 @@ func (b *builder) controller() (*control.Controller, error) {
// Every role setting sees one provider-visible surface, fixed before the
// snapshot freezes registry schemas for cache diagnostics.
if b.opts.Observe == nil {
applyUnifiedProviderToolSurface(t.reg, b.opts.GoalTurnsUnreachable, b.opts.Ablation, pinnedMCPServers(t.mcp.alwaysLoad, t.mcpSchemaKnown))
pinned := pinnedMCPServers(t.mcp.alwaysLoad, t.mcpSchemaKnown)
if b.opts.Composition.Live() {
pinned = nil
}
applyUnifiedProviderToolSurface(t.reg, b.opts.GoalTurnsUnreachable, b.opts.Ablation, pinned)
}
if binding := b.opts.Composition; binding != nil {
if binding.Digest != "" {
t.reg.SetProviderVisibleTools(binding.ToolSurface)
} else if binding.Version.Definition.Tools.Mode == "select" {
} else if !binding.Live() && binding.Version.Definition.Tools.Mode == "select" {
t.reg.SetProviderVisibleTools(nil)
}
}
@@ -775,21 +746,3 @@ func (b *builder) freeze(ctrl *control.Controller) (*BuildResult, error) {
}
return finalizeBuildResult(b.roots, &BuildResult{ComputerDriver: b.opts.ComputerDriver, Controller: ctrl, Snapshot: snap, Runtime: runtimeSet, Owner: b.owner, Extensions: extensionMgr, Dispatcher: dispatcher, ExtensionUI: hub, ProviderResolver: providerResolver, BaseProviderResolver: b.providers.base, Assembly: assembly, Phases: b.timer.done("assemble")}, !b.opts.deferPublish), nil
}
// clock is the one place the system clock enters the assembly: a host supplies
// none, a test supplies its own.
func (b *builder) clock() func() time.Time {
if b.opts.clock != nil {
return b.opts.clock
}
return time.Now
}
// subagentReader hands the controller the store's read side, or nothing when the
// session has no store, so a nil store never travels as a non-nil interface.
func subagentReader(s *delegation.SubagentStore) control.SubagentReader {
if s == nil {
return nil
}
return s
}
+22
View File
@@ -0,0 +1,22 @@
package boot
import (
"time"
"reasonix/internal/runtime/delegation"
"reasonix/internal/session/control"
)
func (b *builder) clock() func() time.Time {
if b.opts.clock != nil {
return b.opts.clock
}
return time.Now
}
func subagentReader(s *delegation.SubagentStore) control.SubagentReader {
if s == nil {
return nil
}
return s
}
+26 -6
View File
@@ -41,7 +41,7 @@ func (b *builder) prepareComposition() error {
if err != nil {
return err
}
b.opts.Composition = &agentcomposition.Binding{Version: v, Resolved: agentcomposition.Clone(v.Definition), Workspace: b.root, Resources: []agentcomposition.Resource{}}
b.opts.Composition = b.compositionBinding(v, b.root)
}
binding := b.opts.Composition
if binding.Workspace != "" && binding.Workspace != b.root {
@@ -51,6 +51,9 @@ func (b *builder) prepareComposition() error {
b.cfg.Agent.Persona = config.PersonaConfig{Prefix: p.Prefix, Suffix: p.Suffix, IncludeRuntimeContext: &p.IncludeRuntimeContext}
// Independent MCP connections keep mutable server state out of sibling sessions.
b.opts.SharedHost = nil
if binding.Live() {
return b.prepareLiveComposition()
}
return b.prepareCompositionMCP()
}
@@ -115,6 +118,17 @@ func (b *builder) bindCompositionSkills() error {
if binding == nil {
return nil
}
if binding.Live() {
b.prompt.skillStore.ConfigureDiscoverySource(b.liveSkillSource())
b.prompt.skillStore.Restrict(func(sk skill.Skill) bool {
return binding.Resolved.Skills.Allows(sk.SlashName()) && (sk.Plugin == "" || binding.Resolved.Extensions.Allows(sk.Plugin))
})
b.prompt.skills = b.prompt.skillStore.List()
if binding.SystemPrompt != "" {
b.prompt.prompt = binding.SystemPrompt
}
return nil
}
var skills []skill.Skill
if binding.Digest != "" {
var err error
@@ -185,7 +199,7 @@ func (b *builder) finishComposition(ctrl *control.Controller, snap *extension.Ru
if snap == nil {
return fmt.Errorf("%w: runtime snapshot missing", agentcomposition.ErrUnavailable)
}
if diagnostics := snap.Diagnostics(); len(diagnostics) > 0 {
if diagnostics := snap.Diagnostics(); len(diagnostics) > 0 && !binding.Live() {
return agentcomposition.Unavailable("assembly", strings.Join(diagnostics, "; "))
}
if binding.Digest != "" {
@@ -196,7 +210,9 @@ func (b *builder) finishComposition(ctrl *control.Controller, snap *extension.Ru
if err := verifySelected("tools", binding.Resolved.Tools, b.tools.reg.AllNames(), func(v string) string { return v }); err != nil {
return err
}
binding.Resolved.Tools = selectionOf(b.tools.reg.AllNames(), func(v string) string { return v })
if !binding.Live() {
binding.Resolved.Tools = selectionOf(b.tools.reg.AllNames(), func(v string) string { return v })
}
binding.SystemPrompt = snap.SystemPrompt()
binding.ToolSchemaDigest = agentcomposition.Digest(b.tools.reg.Schemas())
binding.ToolSurface = []string{}
@@ -209,9 +225,13 @@ func (b *builder) finishComposition(ctrl *control.Controller, snap *extension.Ru
}
}
ctrl.BindComposition(binding)
ctrl.ConfigureCompositionValidation(func() error { return b.validateCompositionAvailability() })
if !binding.Live() {
ctrl.ConfigureCompositionValidation(func() error { return b.validateCompositionAvailability() })
} else {
ctrl.ConfigureCompositionValidation(func() error { return b.ext.mgr.ValidateLiveStartup() })
}
b.tools.reg.Restrict(func(t tool.Tool) bool {
if tool.ReachOf(t) != tool.ReachHostControl && !member(binding.Resolved.Tools, t.Name()) {
if !binding.Live() && tool.ReachOf(t) != tool.ReachHostControl && !member(binding.Resolved.Tools, t.Name()) {
return false
}
if !compositionAdmits(binding.Resolved)(t) {
@@ -219,7 +239,7 @@ func (b *builder) finishComposition(ctrl *control.Controller, snap *extension.Ru
}
return b.opts.Observe == nil || observeAdmits(b.opts.Observe.Tools)(t)
})
if b.tools.caps != nil && b.tools.caps.runtime != nil {
if !binding.Live() && b.tools.caps != nil && b.tools.caps.runtime != nil {
b.tools.caps.runtime.FreezeComposition()
}
return nil
@@ -134,6 +134,9 @@ func compositionPackages(home string, packages []pluginpkg.InstalledPackage, bin
if binding == nil {
return packages, nil
}
if binding.Live() {
return liveCompositionPackages(packages, binding)
}
installed, warnings := pluginpkg.LoadInstalled(home)
if err := verifySelected("extensions", binding.Resolved.Extensions, installed, func(p pluginpkg.InstalledPackage) string { return p.Installed.Name }); err != nil {
return nil, fmt.Errorf("%w: %s", err, strings.Join(warnings, "; "))
@@ -103,6 +103,9 @@ func (b *builder) compositionHookPackage(h hook.ResolvedHook, name string) (agen
func compositionHookSet(opts Options, home, workspace string) ([]hook.ResolvedHook, error) {
store := agentpreset.New(home, workspace)
live := hook.Load(hook.LoadOptions{ProjectRoot: workspace, ReasonixHomeDir: home})
if opts.Composition.Live() {
return hook.CompositionHooks(live, opts.Composition.Resolved), nil
}
byID := map[string]hook.ResolvedHook{}
for i, h := range live {
byID[fmt.Sprintf("%s:%s:%d", h.Scope, h.Event, i)] = h
@@ -34,7 +34,7 @@ func TestCompositionRetainsLinkedSkillAcrossRestart(t *testing.T) {
t.Fatal(err)
}
opts := Options{Home: home, WorkspaceRoot: root, AgentComposition: d.ID, resolvedShell: pinnedEffectShell()}
first, err := BuildRuntime(t.Context(), opts)
first, err := buildSnapshotRuntime(t.Context(), opts)
if err != nil {
t.Fatal(err)
}
@@ -47,7 +47,7 @@ func TestCompositionRetainsLinkedSkillAcrossRestart(t *testing.T) {
t.Fatal(err)
}
opts.AgentComposition, opts.Composition = "", binding
restored, err := BuildRuntime(t.Context(), opts)
restored, err := buildSnapshotRuntime(t.Context(), opts)
if err != nil {
t.Fatal(err)
}
@@ -101,7 +101,7 @@ func TestCompositionLinkedSkillRejectsLinkedResources(t *testing.T) {
if _, err := agentpreset.New(home, root).Save(d, ""); err != nil {
t.Fatal(err)
}
result, err := BuildRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, AgentComposition: d.ID, resolvedShell: pinnedEffectShell()})
result, err := buildSnapshotRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, AgentComposition: d.ID, resolvedShell: pinnedEffectShell()})
if result != nil {
result.Controller.Close()
}
+100
View File
@@ -0,0 +1,100 @@
package boot
import (
"os"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/ext/plugin"
"reasonix/internal/ext/pluginpkg"
)
func newLiveCompositionBinding(v agentcomposition.Version, root string) *agentcomposition.Binding {
return &agentcomposition.Binding{
FormatVersion: agentcomposition.LiveBindingVersion, ResourceMode: agentcomposition.LiveResources,
Version: v, Resolved: agentcomposition.Clone(v.Definition), Workspace: root, Resources: []agentcomposition.Resource{},
}
}
func (b *builder) prepareLiveComposition() error {
binding := b.opts.Composition
entries := []config.PluginEntry{}
for _, entry := range b.cfg.Plugins {
if !binding.Resolved.MCP.Allows(entry.Name) {
continue
}
if owner, owned := b.cfg.PluginPackageOwner(entry.Name); owned && !binding.Resolved.Extensions.Allows(owner) {
continue
}
for _, m := range binding.Resolved.MCP.Members {
if m.ID == entry.Name {
var err error
entry, err = applyCompositionMCPParameters(entry, m.Parameters)
if err != nil {
return err
}
}
}
entries = append(entries, entry)
}
b.cfg.Plugins = entries
extra := []plugin.Spec{}
for _, spec := range b.opts.ExtraPlugins {
if binding.Resolved.MCP.Allows(spec.Name) && (spec.Package == "" || binding.Resolved.Extensions.Allows(spec.Package)) {
entry := compositionMCPEntry(spec)
for _, m := range binding.Resolved.MCP.Members {
if m.ID == spec.Name {
var err error
entry, err = applyCompositionMCPParameters(entry, m.Parameters)
if err != nil {
return err
}
}
}
_, resolved := splitCompositionMCP([]config.PluginEntry{entry}, []plugin.Spec{spec}, b.root)
spec = resolved[0]
extra = append(extra, spec)
}
}
b.opts.ExtraPlugins = extra
return b.resolveLiveHookSelection()
}
func liveCompositionPackages(packages []pluginpkg.InstalledPackage, binding *agentcomposition.Binding) ([]pluginpkg.InstalledPackage, error) {
out := []pluginpkg.InstalledPackage{}
for _, p := range packages {
if !binding.Resolved.Extensions.Allows(p.Installed.Name) {
continue
}
if p.Package.Manifest.Runtime == nil {
continue
}
rt := *p.Package.Manifest.Runtime
rt.TightenOnly = true
p.Package.Manifest.Runtime = &rt
for _, m := range binding.Resolved.Extensions.Members {
if m.ID != p.Installed.Name || p.Package.Manifest.Runtime == nil {
continue
}
params, err := parameters(m.Parameters)
if err != nil {
return nil, err
}
rt := *p.Package.Manifest.Runtime
if params.Args != nil {
rt.Args = *params.Args
}
if params.Env != nil {
rt.Env = map[string]string{}
for key, value := range params.Env {
rt.Env[key] = os.ExpandEnv(value)
}
}
if params.TimeoutMillis != nil {
rt.TimeoutMillis = *params.TimeoutMillis
}
p.Package.Manifest.Runtime = &rt
}
out = append(out, p)
}
return out, nil
}
@@ -0,0 +1,127 @@
package boot
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/ext/extension/sidecar"
"reasonix/internal/ext/pluginpkg"
"reasonix/internal/state/agentpreset"
)
func TestLiveCompositionDefersExtensionUntilInvocationAndClosesIt(t *testing.T) {
home, root := compositionTestWorkspace(t, "openai")
installBootFakePlugin(t, home, "lexicon", map[string]any{
"capabilities": []string{"tools"},
"tools": []map[string]any{{"name": "lookup", "description": "Look up a term", "readOnly": true, "inputSchema": map[string]any{"type": "object"}}},
"env": map[string]string{bootFakeEnvInitResult: `{"protocolVersion":"2","name":"lexicon","version":"1.0.0","stateSchemaVersion":0,"tools":["lookup"]}`},
})
d := agentcomposition.Builtins()[0]
d.ID = "user:lazy-extension"
if _, err := agentpreset.New(home, root).Save(d, ""); err != nil {
t.Fatal(err)
}
res, err := BuildRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, AgentComposition: d.ID, resolvedShell: pinnedEffectShell()})
if err != nil {
t.Fatal(err)
}
defer res.Controller.Close()
if res.Extensions == nil || res.Extensions.Client("lexicon") != nil {
t.Fatal("extension was started or its lifecycle owner was lost")
}
before := agentcomposition.Digest(res.Assembly.Registry.Schemas())
target, ok := res.Assembly.Registry.Get("ext__lexicon__lookup")
if !ok {
t.Fatal("manifest tool is missing")
}
result, err := target.Execute(t.Context(), json.RawMessage(`{"term":"lease"}`))
if err != nil || !strings.Contains(result, "looked up") {
t.Fatalf("lazy invocation: %q %v", result, err)
}
client := res.Extensions.Client("lexicon")
if client == nil || client.Exited() {
t.Fatal("invocation did not activate the runtime")
}
cancelled, cancel := context.WithCancel(t.Context())
cancel()
if err := res.Extensions.WithReady(cancelled, "lexicon", func(context.Context, *sidecar.Client) error {
t.Error("cancelled call reached execution")
return nil
}); err == nil {
t.Fatal("cancelled call was accepted")
}
entered, release, finished := make(chan struct{}), make(chan struct{}), make(chan error, 1)
go func() {
finished <- res.Extensions.WithReady(t.Context(), "lexicon", func(_ context.Context, _ *sidecar.Client) error {
close(entered)
<-release
return nil
})
}()
<-entered
parallel := make(chan error, 1)
go func() {
parallel <- res.Extensions.WithReady(t.Context(), "lexicon", func(_ context.Context, _ *sidecar.Client) error { return nil })
}()
select {
case err := <-parallel:
if err != nil {
t.Error(err)
}
case <-time.After(2 * time.Second):
t.Error("unrelated calls to one ready extension were serialized")
}
installed, _ := pluginpkg.LoadInstalled(home)
entry := installed[0].Installed
if err := pluginpkg.Upsert(home, entry); err != nil {
close(release)
t.Fatal(err)
}
next, err := res.Extensions.EnsureReady(t.Context(), "lexicon")
if err != nil || next == client || client.Exited() {
close(release)
t.Fatalf("updated runtime reused or prematurely closed the in-flight instance: %v", err)
}
close(release)
if err := <-finished; err != nil {
t.Fatal(err)
}
if !client.Exited() {
t.Fatal("retired instance survived its final call")
}
if after := agentcomposition.Digest(res.Assembly.Registry.Schemas()); before != after {
t.Fatal("activation changed provider-visible tools")
}
installed, _ = pluginpkg.LoadInstalled(home)
entry = installed[0].Installed
entry.Enabled = false
if err := pluginpkg.Upsert(home, entry); err != nil {
t.Fatal(err)
}
if _, err := target.Execute(t.Context(), json.RawMessage(`{}`)); err == nil {
t.Fatal("disabled extension remained callable")
}
proxy, _ := res.Assembly.Registry.Get("use_capability")
if _, err := proxy.Execute(t.Context(), json.RawMessage(`{"action":"list"}`)); err != nil {
t.Fatal(err)
}
if _, ok := res.Assembly.Registry.Get(target.Name()); ok {
t.Fatal("disabled extension remained in the capability directory")
}
entry.Enabled = true
if err := pluginpkg.Upsert(home, entry); err != nil {
t.Fatal(err)
}
if _, err := target.Execute(t.Context(), json.RawMessage(`{}`)); err != nil {
t.Fatal(err)
}
client = res.Extensions.Client("lexicon")
res.Controller.Close()
if !client.Exited() {
t.Fatal("closing the session left its deferred process running")
}
}
@@ -0,0 +1,12 @@
package boot
import "reasonix/internal/ext/hook"
func (b *builder) resolveLiveHookSelection() error {
binding := b.opts.Composition
if binding.Digest == "" {
hooks := hook.Load(hook.LoadOptions{ProjectRoot: b.root, ReasonixHomeDir: b.roots.Home()})
binding.Resolved.Hooks = hook.ResolveCompositionSelection(binding.Resolved.Hooks, hooks)
}
return nil
}
@@ -0,0 +1,32 @@
package boot
import (
"reasonix/internal/contract/config"
"reasonix/internal/ext/plugin"
"reasonix/internal/ext/pluginspec"
"reasonix/internal/runtime/usecap"
)
func (b *builder) liveMCPInventory() usecap.InventorySource {
return func() ([]config.PluginEntry, []plugin.Spec, map[string]bool, error) {
cfg, err := b.roots.LoadForRootReadOnly(b.root)
if err != nil {
return nil, nil, nil, err
}
current := &builder{cfg: cfg, opts: b.opts, roots: b.roots, root: b.root}
if err := current.prepareLiveComposition(); err != nil {
return nil, nil, nil, err
}
options := b.tools.specOptions
options.PackageOwners = pluginspec.PackageOwners(cfg)
plan := resolveMCPSpecs(current.opts, cfg, b.root, options)
entries := append([]config.PluginEntry(nil), cfg.Plugins...)
specs := pluginspec.ForRootWithOptions(cfg.Plugins, b.root, options)
for _, spec := range plan.extra {
entries = append(entries, compositionMCPEntry(spec))
specs = append(specs, spec)
plan.enabled[spec.Name] = true
}
return entries, specs, plan.enabled, nil
}
}
@@ -0,0 +1,24 @@
package boot
import (
"context"
"reasonix/internal/contract/tool"
"reasonix/internal/ext/plugin"
)
func registerLiveCompositionMCP(ctx context.Context, host *plugin.Host, reg *tool.Registry, plan mcpSpecPlan) ([]plugin.Spec, map[string]bool) {
known := map[string]bool{}
for _, specs := range [][]plugin.Spec{plan.configured, plan.extra} {
for _, spec := range specs {
plugin.ApplyDisabledMCPPolicy(reg, spec)
cache, _ := plugin.LoadCachedSchemaForSpec(spec)
known[spec.Name] = cache != nil && len(cache.Tools) > 0
addTools(reg, plugin.LazyToolset(spec, cache, host, reg, ctx, false))
if (plan.alwaysLoad[spec.Name] || plan.autoConnect[spec.Name]) && plugin.ResolveStoredAuthorization(ctx, spec).ServerAuthorized() {
host.EnsureConnectedInBackground(ctx, spec)
}
}
}
return plan.configured, known
}
@@ -0,0 +1,57 @@
package boot
import (
"encoding/json"
"fmt"
"testing"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/ext/plugin"
"reasonix/internal/state/agentpreset"
)
func TestLiveCompositionResourceReadRechecksServerGrant(t *testing.T) {
home, root := compositionTestWorkspace(t, "openai")
t.Setenv("REASONIX_HOME", home)
server := newResourceMCPServer(t, true)
configText := fmt.Sprintf("[tools]\nmcp_resources=true\n[[plugins]]\nname='res'\ntype='http'\nurl=%q\n", server.URL)
writeFile(t, home, "config.toml", configText)
warm, err := Build(t.Context(), Options{Home: home, WorkspaceRoot: root, resolvedShell: pinnedEffectShell()})
if err != nil {
t.Fatal(err)
}
if _, err := warm.ConnectMCPServer(warm.ConfiguredMCPServers()[0].Entry); err != nil {
warm.Close()
t.Fatal(err)
}
warm.Close()
d := agentcomposition.Builtins()[0]
d.ID = "user:resources"
if _, err := agentpreset.New(home, root).Save(d, ""); err != nil {
t.Fatal(err)
}
res, err := BuildRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, AgentComposition: d.ID, resolvedShell: pinnedEffectShell()})
if err != nil {
t.Fatal(err)
}
defer res.Controller.Close()
target, ok := res.Assembly.Registry.Get(plugin.ResourceReadToolName)
if !ok {
t.Fatal("cached resource capability is missing")
}
before := agentcomposition.Digest(res.Assembly.Registry.Schemas())
args := json.RawMessage(`{"server":"res","uri":"doc://handbook"}`)
if _, err := target.Execute(t.Context(), args); err != nil {
t.Fatalf("authorized lazy resource read failed: %v", err)
}
writeFile(t, home, "config.toml", "[tools]\nmcp_resources=true\n")
if _, err := target.Execute(t.Context(), args); err == nil {
t.Fatal("removed server remained readable")
}
if len(server.readURIs()) != 1 {
t.Fatal("removed server received another resource read")
}
if before != agentcomposition.Digest(res.Assembly.Registry.Schemas()) {
t.Fatal("resource connection changed provider-visible schema")
}
}
@@ -0,0 +1,22 @@
package boot
import (
"io"
"reasonix/internal/ext/skill"
)
func (b *builder) liveSkillSource() func() *skill.Store {
return func() *skill.Store {
cfg, err := b.roots.LoadForRootReadOnly(b.root)
if err != nil {
return nil
}
return skill.New(skill.Options{
ProjectRoot: b.root, ReasonixHomeDir: b.roots.Home(), CustomPaths: cfg.SkillCustomPaths(),
PluginPaths: cfg.PluginPackageSkillOwners(), PluginAgentPaths: cfg.PluginPackageAgentOwners(),
ExcludedPaths: cfg.SkillExcludedPaths(), DisabledNames: func() []string { return disabledSkillNames(cfg, b.root) },
MaxDepth: cfg.SkillMaxDepth(), Stderr: io.Discard, SuppressWarnings: true,
})
}
}
@@ -0,0 +1,167 @@
package boot
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/ext/plugin"
"reasonix/internal/state/agentpreset"
)
func TestLiveCompositionDoesNotCaptureUnusedSkillAssets(t *testing.T) {
home, root := compositionTestWorkspace(t, "openai")
writeFile(t, home, "skills/templates/SKILL.md", "---\nname: templates\ndescription: template library\n---\nRead templates when needed.")
// An unused link cannot participate in assembling a capability grant.
if err := os.Symlink(t.TempDir(), filepath.Join(home, "skills/templates/unused")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
d := agentcomposition.Builtins()[0]
d.ID = "user:live"
if _, err := agentpreset.New(home, root).Save(d, ""); err != nil {
t.Fatal(err)
}
res, err := BuildRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, AgentComposition: d.ID, resolvedShell: pinnedEffectShell()})
if err != nil {
t.Fatal(err)
}
defer res.Controller.Close()
b := res.Controller.CompositionBinding()
data, err := json.Marshal(b)
if err != nil {
t.Fatal(err)
}
var format map[string]any
if err := json.Unmarshal(data, &format); err != nil {
t.Fatal(err)
}
if format["formatVersion"] != float64(2) || format["resourceMode"] != "live" || len(b.Resources) != 0 {
t.Fatalf("binding retained resources or lacks live format: %s", data)
}
if b.Resolved.Skills.Mode != agentcomposition.Inherit {
t.Fatal("dynamic inheritance was converted into a fixed list")
}
writeFile(t, home, "skills/added/SKILL.md", "---\nname: added\ndescription: added later\n---\nNEW")
found := false
for _, sk := range res.Controller.Skills() {
found = found || sk.Name == "added"
}
if !found {
t.Fatal("inherited skill added after selection is missing")
}
}
func TestLiveCompositionExplicitAutoStartDoesNotBlockSelection(t *testing.T) {
started, release := make(chan struct{}), make(chan struct{})
var once sync.Once
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
once.Do(func() { close(started) })
select {
case <-release:
case <-r.Context().Done():
}
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
defer close(release)
home, root := compositionTestWorkspace(t, "openai")
writeFile(t, home, "config.toml", "[[plugins]]\nname='background'\ntype='http'\nurl='"+server.URL+"'\nauto_start=true\n")
t.Setenv("REASONIX_HOME", home)
cfg, err := config.RootsForHome(home).LoadForRootReadOnly(root)
if err != nil || len(cfg.Plugins) != 1 || cfg.Plugins[0].AutoStart == nil || !*cfg.Plugins[0].AutoStart || cfg.Plugins[0].Source != config.MCPSourceUserConfig {
t.Fatalf("automatic connection fixture was not loaded: %v", err)
}
d := agentcomposition.Builtins()[0]
d.ID = "user:background"
if _, err := agentpreset.New(home, root).Save(d, ""); err != nil {
t.Fatal(err)
}
res, err := BuildRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, AgentComposition: d.ID, resolvedShell: pinnedEffectShell()})
if err != nil {
t.Fatal(err)
}
defer res.Controller.Close()
select {
case <-started:
case <-time.After(10 * time.Second):
t.Fatalf("explicit automatic connection did not start: failures=%+v source=%s", res.Controller.Host().Failures(), cfg.Plugins[0].Source)
}
}
func TestLiveCompositionLeavesUncachedMCPIdle(t *testing.T) {
var calls atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
home, root := compositionTestWorkspace(t, "openai")
d := agentcomposition.Builtins()[0]
d.ID = "user:offline"
if _, err := agentpreset.New(home, root).Save(d, ""); err != nil {
t.Fatal(err)
}
res, err := BuildRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, AgentComposition: d.ID, resolvedShell: pinnedEffectShell(),
ExtraPlugins: []plugin.Spec{{Name: "offline", Type: "http", URL: server.URL, Authorized: true}}})
if err != nil {
t.Fatal(err)
}
defer res.Controller.Close()
proxy, ok := res.Assembly.Registry.Get("use_capability")
if !ok {
t.Fatal("stable capability entry is missing")
}
for _, args := range []string{`{"action":"list"}`, `{"action":"search","query":"offline"}`, `{"action":"inspect","capability_id":"mcp-server:offline"}`} {
if _, err := proxy.Execute(t.Context(), json.RawMessage(args)); err != nil {
t.Fatal(err)
}
}
if calls.Load() != 0 {
t.Fatalf("selection/catalog caused %d MCP requests", calls.Load())
}
if len(res.Controller.CompositionBinding().Resources) != 0 {
t.Fatal("MCP resources captured")
}
}
func TestLiveCompositionSkillWhitelistAndRevocation(t *testing.T) {
home, root := compositionTestWorkspace(t, "openai")
writeFile(t, home, "skills/allowed.md", "---\nname: allowed\ndescription: allowed\n---\nOLD")
writeFile(t, home, "skills/denied.md", "---\nname: denied\ndescription: denied\n---\nDENIED")
d := agentcomposition.Builtins()[0]
d.ID = "user:limited"
d.Skills = agentcomposition.Selection{Mode: agentcomposition.Select, Members: []agentcomposition.Module{{ID: "allowed"}}}
if _, err := agentpreset.New(home, root).Save(d, ""); err != nil {
t.Fatal(err)
}
res, err := BuildRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, AgentComposition: d.ID, resolvedShell: pinnedEffectShell()})
if err != nil {
t.Fatal(err)
}
defer res.Controller.Close()
skills := res.Controller.Skills()
if len(skills) != 1 || skills[0].Name != "allowed" {
t.Fatalf("whitelist not applied: %+v", skills)
}
writeFile(t, home, "skills/allowed.md", "---\nname: allowed\ndescription: allowed\n---\nNEW")
if got := res.Controller.Skills(); len(got) != 1 || got[0].Body != "NEW" {
t.Fatalf("live update missing: %+v", got)
}
if err := config.DefaultActivationStore().SetSkillEnabled("allowed", root, config.ActivationProject, false); err != nil {
t.Fatal(err)
}
if len(res.Controller.Skills()) != 0 {
t.Fatal("revoked skill remains visible")
}
if err := res.Controller.CompositionExecutionError(); err != nil {
t.Fatalf("revoked optional skill blocked unrelated conversation: %v", err)
}
}
+1 -23
View File
@@ -108,29 +108,7 @@ func (b *builder) prepareCompositionMCP() error {
}
func applyCompositionMCPParameters(entry config.PluginEntry, raw json.RawMessage) (config.PluginEntry, error) {
p, err := parameters(raw)
if err != nil {
return entry, err
}
if p.Args != nil {
entry.Args = *p.Args
}
if p.URL != nil {
entry.URL = *p.URL
}
if p.Env != nil {
entry.Env = p.Env
}
if p.Headers != nil {
entry.Headers = p.Headers
}
if p.CallTimeoutSeconds != nil {
entry.CallTimeoutSeconds = *p.CallTimeoutSeconds
}
if p.StartupTimeoutSeconds != nil {
entry.StartupTimeoutSeconds = *p.StartupTimeoutSeconds
}
return entry, nil
return agentpreset.ApplyMCPParameters(entry, raw)
}
func compositionMCPEntry(spec plugin.Spec) config.PluginEntry {
@@ -10,7 +10,7 @@ import (
)
func (b *builder) bindCompositionMCPContracts(ctx context.Context) error {
if b.opts.Composition == nil {
if b.opts.Composition == nil || b.opts.Composition.Live() {
return nil
}
binding := b.opts.Composition
@@ -41,7 +41,7 @@ load = "always"
t.Fatal(err)
}
opts := Options{Home: home, WorkspaceRoot: root, AgentComposition: "user:followed", resolvedShell: pinnedEffectShell()}
first, err := BuildRuntime(t.Context(), opts)
first, err := buildSnapshotRuntime(t.Context(), opts)
if err != nil {
t.Fatal(err)
}
@@ -61,7 +61,7 @@ load = "always"
if len(rec.requests()) != 0 || server.called("extra") || first.Controller.CompositionBinding().Digest != binding.Digest {
t.Fatal("a followed change reached the provider or rewrote the binding")
}
fresh, err := BuildRuntime(t.Context(), opts)
fresh, err := buildSnapshotRuntime(t.Context(), opts)
if err != nil {
t.Fatal(err)
}
@@ -12,7 +12,7 @@ import (
)
func (b *builder) isolateCompositionMCPPackages() error {
if b.opts.Composition == nil {
if b.opts.Composition == nil || b.opts.Composition.Live() {
return nil
}
packages, err := resource[retainedExtension](b.opts.Composition, "extension")
+15 -4
View File
@@ -1,6 +1,7 @@
package boot
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -22,6 +23,14 @@ func (compositionLateMCPTool) MCPServerName() string { return "docs" }
func (compositionLateMCPTool) MCPRawToolName() string { return "unbound" }
func TestCompositionSessionsKeepIndependentMCPParametersAndConnections(t *testing.T) {
testCompositionSessionParameters(t, buildSnapshotRuntime, true)
}
func TestLiveCompositionSessionsKeepIndependentMCPParametersAndConnections(t *testing.T) {
testCompositionSessionParameters(t, BuildRuntime, false)
}
func testCompositionSessionParameters(t *testing.T, build func(context.Context, Options) (*BuildResult, error), snapshot bool) {
var mu sync.Mutex
nextID := 0
calls := map[string]int{}
@@ -72,7 +81,7 @@ func TestCompositionSessionsKeepIndependentMCPParametersAndConnections(t *testin
if _, err := store.Save(d, ""); err != nil {
t.Fatal(err)
}
built, err := BuildRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, Model: "test-model", AgentComposition: d.ID, resolvedShell: pinnedEffectShell(), ExtraPlugins: []plugin.Spec{{Name: "docs", Type: "http", URL: server.URL, Authorized: true}}})
built, err := build(t.Context(), Options{Home: home, WorkspaceRoot: root, Model: "test-model", AgentComposition: d.ID, resolvedShell: pinnedEffectShell(), ExtraPlugins: []plugin.Spec{{Name: "docs", Type: "http", URL: server.URL, Authorized: true}}})
if err != nil {
t.Fatal(err)
}
@@ -81,9 +90,11 @@ func TestCompositionSessionsKeepIndependentMCPParametersAndConnections(t *testin
if !ok {
t.Fatal("reader missing")
}
built.Assembly.Registry.Add(compositionLateMCPTool{Tool: reader})
if _, ok := built.Assembly.Registry.Get("mcp__docs__unbound"); ok {
t.Fatal("late tool widened a selected MCP server's bound surface")
if snapshot {
built.Assembly.Registry.Add(compositionLateMCPTool{Tool: reader})
if _, ok := built.Assembly.Registry.Get("mcp__docs__unbound"); ok {
t.Fatal("late tool widened a selected MCP server's bound surface")
}
}
results = append(results, built)
}
@@ -55,6 +55,11 @@ func TestProjectCompositionCannotChangeAnAuthorizedMCPLaunch(t *testing.T) {
}
built, err := BuildRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, AgentComposition: d.ID, ExtraPlugins: []plugin.Spec{spec}, resolvedShell: pinnedEffectShell()})
if built != nil {
proxy, ok := built.Assembly.Registry.Get("use_capability")
if !ok {
t.Fatal("capability proxy missing")
}
_, err = proxy.Execute(t.Context(), json.RawMessage(`{"action":"call","capability_id":"mcp-server:authorized"}`))
built.Controller.Close()
}
_, markerErr := os.Stat(marker)
@@ -2,7 +2,6 @@ package boot
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -10,6 +9,7 @@ import (
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/ext/hook"
"reasonix/internal/state/agentpreset"
)
@@ -74,16 +74,22 @@ func TestProjectCompositionCannotActivateUnauthorizedProgramsOrChooseDefault(t *
t.Fatal("unbound session never reached its provider")
}
ctrl.Close()
ctrl, err = Build(t.Context(), Options{Home: home, WorkspaceRoot: root, AgentComposition: d.ID, resolvedShell: pinnedEffectShell()})
if ctrl != nil {
ctrl.Close()
built, err := BuildRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, AgentComposition: d.ID, resolvedShell: pinnedEffectShell()})
if err != nil {
t.Fatal(err)
}
if !errors.Is(err, agentcomposition.ErrUnavailable) {
t.Fatalf("unauthorized %s selection was not refused: %v", kind, err)
defer built.Controller.Close()
built.Controller.HookRunner().SessionStart(t.Context())
proxy, ok := built.Assembly.Registry.Get("use_capability")
if !ok {
t.Fatal("capability proxy missing")
}
var diagnostic *agentcomposition.DiagnosticError
if !errors.As(err, &diagnostic) || len(diagnostic.Diagnostics) != 1 || diagnostic.Diagnostics[0].Module != kind+":"+id {
t.Fatalf("wrong missing authority: %v", err)
resolved, err := proxy.(tool.CallResolver).ResolveCall(t.Context(), json.RawMessage(`{"action":"call","capability_id":"mcp-server:unapproved"}`))
if err == nil && !resolved.Unavailable {
t.Fatal("unapproved server accepted a call")
}
if built.Extensions != nil && built.Extensions.Client("unapproved") != nil {
t.Fatal("unapproved extension was activated")
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("unauthorized %s started a program: %v", kind, err)
@@ -67,20 +67,20 @@ func TestCompositionLinkedExtensionResources(t *testing.T) {
if err != nil || !info.IsDir() {
t.Fatalf("linked install did not register real root: %v", err)
}
ordinary, err := BuildRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, resolvedShell: pinnedEffectShell()})
ordinary, err := buildSnapshotRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, resolvedShell: pinnedEffectShell()})
if err != nil {
t.Fatal(err)
}
ordinary.Controller.Close()
opts := resourcePreset(t, home, root)
bound, err := BuildRuntime(t.Context(), opts)
bound, err := buildSnapshotRuntime(t.Context(), opts)
if err != nil {
t.Fatalf("linked extension was rejected: %v", err)
}
defer bound.Controller.Close()
writeFile(t, source, "notes.txt", "CHANGED")
opts.AgentComposition, opts.Composition = "", bound.Controller.CompositionBinding()
restored, err := BuildRuntime(t.Context(), opts)
restored, err := buildSnapshotRuntime(t.Context(), opts)
if err != nil {
t.Fatal(err)
}
@@ -114,7 +114,7 @@ func TestCompositionLinkedHookResources(t *testing.T) {
settings := hook.Settings{Hooks: map[hook.Event][]hook.HookConfig{hook.SessionStart: {{Command: "cat '" + filepath.Join(link, "entry.sh") + "'"}}}}
raw, _ := json.Marshal(settings)
writeFile(t, home, hook.SettingsFilename, string(raw))
ordinary, err := BuildRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, resolvedShell: pinnedEffectShell()})
ordinary, err := buildSnapshotRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, resolvedShell: pinnedEffectShell()})
if err != nil {
t.Fatal(err)
}
@@ -123,7 +123,7 @@ func TestCompositionLinkedHookResources(t *testing.T) {
t.Fatalf("ordinary hook failed: %v", got)
}
opts := resourcePreset(t, home, root)
bound, err := BuildRuntime(t.Context(), opts)
bound, err := buildSnapshotRuntime(t.Context(), opts)
if err != nil {
t.Fatal(err)
}
@@ -132,7 +132,7 @@ func TestCompositionLinkedHookResources(t *testing.T) {
t.Fatal(err)
}
opts.AgentComposition, opts.Composition = "", bound.Controller.CompositionBinding()
restored, err := BuildRuntime(t.Context(), opts)
restored, err := buildSnapshotRuntime(t.Context(), opts)
if err != nil {
t.Fatal(err)
}
@@ -152,13 +152,13 @@ func TestCompositionUnavailableMCPNamesDependency(t *testing.T) {
opts := Options{Home: home, WorkspaceRoot: root, resolvedShell: pinnedEffectShell(), ExtraPlugins: []plugin.Spec{{
Name: "audit", Type: "http", URL: server.URL, Authorized: true,
}}}
ordinary, err := BuildRuntime(t.Context(), opts)
ordinary, err := buildSnapshotRuntime(t.Context(), opts)
if err != nil {
t.Fatal(err)
}
ordinary.Controller.Close()
opts.AgentComposition = resourcePreset(t, home, root).AgentComposition
bound, err := BuildRuntime(t.Context(), opts)
bound, err := buildSnapshotRuntime(t.Context(), opts)
if bound != nil {
bound.Controller.Close()
}
@@ -40,7 +40,7 @@ func TestCompositionRetainsPersonaSkillsCommandsAndHooksAcrossSourceDeletion(t *
t.Fatal(err)
}
opts := Options{Home: home, WorkspaceRoot: root, Model: "test-model", AgentComposition: d.ID, resolvedShell: pinnedEffectShell()}
first, err := BuildRuntime(t.Context(), opts)
first, err := buildSnapshotRuntime(t.Context(), opts)
if err != nil {
t.Fatal(err)
}
@@ -71,7 +71,7 @@ func TestCompositionRetainsPersonaSkillsCommandsAndHooksAcrossSourceDeletion(t *
t.Fatal("reload followed command source")
}
opts.AgentComposition, opts.Composition = "", first.Controller.CompositionBinding()
restored, err := BuildRuntime(t.Context(), opts)
restored, err := buildSnapshotRuntime(t.Context(), opts)
if err != nil {
t.Fatal(err)
}
@@ -91,7 +91,7 @@ func TestCompositionRetainsPersonaSkillsCommandsAndHooksAcrossSourceDeletion(t *
if got := restored.Controller.HookRunner().SessionStart(t.Context()); len(got) != 1 || got[0] != "ORIGINAL HOOK" {
t.Fatalf("restored hook=%q", got)
}
if _, err := BuildRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, Model: "test-model", AgentComposition: d.ID, resolvedShell: pinnedEffectShell()}); !errors.Is(err, agentpreset.ErrNotFound) {
if _, err := buildSnapshotRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, Model: "test-model", AgentComposition: d.ID, resolvedShell: pinnedEffectShell()}); !errors.Is(err, agentpreset.ErrNotFound) {
t.Fatalf("deleted definition remained selectable: %v", err)
}
if err := config.DefaultActivationStore().SetSkillEnabled("frozen", root, config.ActivationProject, false); err != nil {
@@ -0,0 +1,15 @@
package boot
import (
"context"
"reasonix/internal/contract/agentcomposition"
)
// Snapshot fixtures exercise the retained v1 writer and real recovery pipeline.
func buildSnapshotRuntime(ctx context.Context, opts Options) (*BuildResult, error) {
b := &builder{timer: newPhaseTimer(), compositionBinding: func(v agentcomposition.Version, root string) *agentcomposition.Binding {
return &agentcomposition.Binding{Version: v, Resolved: agentcomposition.Clone(v.Definition), Workspace: root, Resources: []agentcomposition.Resource{}}
}}
return b.build(ctx, opts)
}
@@ -0,0 +1,43 @@
package boot
import (
"context"
"encoding/json"
"strings"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/tool"
"reasonix/internal/ext/extension/sidecar"
)
type extensionDiscoveryTool struct {
pluginID string
clients *sidecar.Manager
registry *tool.Registry
}
func (t extensionDiscoveryTool) Name() string {
name := strings.ReplaceAll(t.pluginID, ".", "_")
return "connect_ext__" + name[:min(len(name), 42)] + "_" + agentcomposition.Digest(t.pluginID)[:8]
}
func (t extensionDiscoveryTool) Description() string {
return "Connect extension " + t.pluginID + " and list its available tools."
}
func (t extensionDiscoveryTool) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`)
}
func (t extensionDiscoveryTool) ExtensionPackageName() string { return t.pluginID }
func (t extensionDiscoveryTool) ReadOnly() bool { return false }
func (t extensionDiscoveryTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
client, err := t.clients.EnsureReady(ctx, t.pluginID)
if err != nil {
return "", err
}
for _, decl := range client.DeclaredTools() {
if client.ServesTool(decl.Name) {
t.registry.Add(extensionTool{name: extensionToolName(t.pluginID, decl.Name), pluginID: t.pluginID, decl: decl, clients: t.clients})
}
}
data, err := json.Marshal(client.DeclaredTools())
return string(data), err
}
+32 -6
View File
@@ -8,7 +8,9 @@ import (
"strings"
"time"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/tool"
"reasonix/internal/ext/extension/protocol"
"reasonix/internal/ext/extension/sidecar"
"reasonix/internal/ext/pluginpkg"
)
@@ -34,11 +36,20 @@ func (t extensionTool) ReadOnly() bool { return t.decl.ReadOnly }
func (t extensionTool) ExtensionPackageName() string { return t.pluginID }
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)
var res protocol.ToolCallResult
err := t.clients.WithReady(ctx, t.pluginID, func(callCtx context.Context, client *sidecar.Client) error {
if !client.ServesTool(t.decl.Name) {
return fmt.Errorf("extension %s no longer serves tool %s", t.pluginID, t.decl.Name)
}
for _, current := range client.DeclaredTools() {
if current.Name == t.decl.Name && agentcomposition.Digest(current) != agentcomposition.Digest(t.decl) {
return agentcomposition.Unavailable("extension:"+t.pluginID, "Tool contract changed before dispatch; refresh the capability directory and retry.")
}
}
var callErr error
res, callErr = client.CallTool(callCtx, t.decl.Name, args, extensionToolTimeout)
return callErr
})
if err != nil {
return "", fmt.Errorf("the %s extension could not run %s: %w", t.pluginID, t.decl.Name, err)
}
@@ -61,7 +72,22 @@ func addExtensionTools(reg *tool.Registry, mgr *sidecar.Manager, warn func(strin
if mgr == nil {
return
}
for _, client := range mgr.Clients() {
if mgr.OnDemand() {
reg.RemovePrefix(tool.ExtensionNamePrefix)
reg.RemovePrefix("connect_ext__")
}
for _, p := range mgr.DeferredPackages() {
for _, decl := range p.Package.Manifest.Runtime.Tools {
name := extensionToolName(p.Installed.Name, decl.Name)
if len(name) <= 64 {
reg.Add(extensionTool{name: name, pluginID: p.Installed.Name, decl: decl, clients: mgr})
}
}
if p.Package.Manifest.Runtime.DynamicTools {
reg.Add(extensionDiscoveryTool{pluginID: p.Installed.Name, clients: mgr, registry: reg})
}
}
for _, client := range mgr.ToolClients() {
for _, decl := range client.DeclaredTools() {
if !client.ServesTool(decl.Name) {
continue
+5 -1
View File
@@ -12,13 +12,17 @@ import (
// resources, only when the user turned it on in their own config and a server
// in this session advertised the capability, so every other session's tool
// block stays as it was.
func registerResourceTool(host *plugin.Host, reg *tool.Registry, cfg *config.Config, plan mcpSpecPlan) {
func registerResourceTool(host *plugin.Host, reg *tool.Registry, cfg *config.Config, plan mcpSpecPlan, live bool) {
if !cfg.Tools.MCPResources {
return
}
if len(cfg.Tools.Enabled) > 0 && !slices.Contains(cfg.Tools.Enabled, plugin.ResourceReadToolName) {
return
}
if live {
reg.Add(plugin.ResourceReadTool(host))
return
}
for _, specs := range [][]plugin.Spec{plan.extra, plan.configured} {
if slices.ContainsFunc(specs, host.DeclaresResources) {
reg.Add(plugin.ResourceReadTool(host))
+10 -5
View File
@@ -12,10 +12,11 @@ import (
// touched: the configured and host-session specs, the names config has
// enabled, and the configured servers whose tools load into the schema.
type mcpSpecPlan struct {
configured []plugin.Spec
extra []plugin.Spec
enabled map[string]bool
alwaysLoad map[string]bool
configured []plugin.Spec
extra []plugin.Spec
enabled map[string]bool
alwaysLoad map[string]bool
autoConnect map[string]bool
}
// resolveMCPSpecs resolves the enabled servers and normalizes the host-session
@@ -24,9 +25,13 @@ func resolveMCPSpecs(opts Options, cfg *config.Config, root string, specOptions
autoStart := cfg.EnabledPlugins(root, config.DefaultActivationStore())
enabled := make(map[string]bool, len(autoStart))
alwaysLoad := map[string]bool{}
autoConnect := map[string]bool{}
for _, e := range autoStart {
if name := strings.TrimSpace(e.Name); name != "" {
enabled[name] = true
if e.AutoStart != nil && *e.AutoStart {
autoConnect[name] = true
}
if cfg.MCPAlwaysLoad(e) {
alwaysLoad[name] = true
}
@@ -70,5 +75,5 @@ func resolveMCPSpecs(opts Options, cfg *config.Config, root string, specOptions
extra[i].Stderr = opts.Stderr
}
}
return mcpSpecPlan{configured: configured, extra: extra, enabled: enabled, alwaysLoad: alwaysLoad}
return mcpSpecPlan{configured: configured, extra: extra, enabled: enabled, alwaysLoad: alwaysLoad, autoConnect: autoConnect}
}
+65
View File
@@ -0,0 +1,65 @@
package boot
import (
"crypto/rand"
"reasonix/internal/ext/mcplaunch"
"reasonix/internal/ext/plugin"
"reasonix/internal/ext/pluginspec"
"time"
)
// wireMCP registers the MCP servers and LSP tools and chains their shutdown
// into the controller's cleanup. A caller-supplied shared host lets
// controllers for one workspace reuse running MCP processes; its owner closes it.
func (b *builder) wireMCP() error {
opts, cfg, root, t := b.opts, b.cfg, b.root, &b.tools
t.host = opts.SharedHost
if t.host == nil {
t.host = plugin.NewHost()
}
// A lazy server connects in the background; without a status sink every
// status view keeps showing what it saw at boot.
t.host.SetStatusSink(opts.Sink)
t.specOptions = pluginspec.Options{
DefaultStartupTimeout: time.Duration(cfg.MCPStartupTimeoutSeconds()) * time.Second,
DefaultCallTimeout: time.Duration(cfg.MCPCallTimeoutSeconds()) * time.Second,
LaunchManager: mcplaunch.ForWorkspace(b.roots.Home(), root),
ConfigSource: "workspace_config",
StateHome: b.roots.Home(),
WriterRoots: t.env.writeRoots,
WritableRoots: t.env.writeRoots,
ForbidReadRoots: t.env.forbidReadRoots,
Network: t.env.network,
PackageOwners: pluginspec.PackageOwners(cfg),
OAuthHTTPClient: b.balanceClient,
}
if opts.Composition != nil {
t.specOptions.ProcessScope = rand.Text()
}
t.mcp = resolveMCPSpecs(opts, cfg, root, t.specOptions)
b.cleanup = t.host.Close
if opts.SharedHost != nil {
b.cleanup = func() {}
}
if err := b.isolateCompositionMCPPackages(); err != nil {
return err
}
reportProjectMCPAwaitingApproval(b.sink, cfg, root)
if opts.Composition.Live() {
t.configSpecs, t.mcpSchemaKnown = registerLiveCompositionMCP(b.ctx, t.host, t.reg, t.mcp)
} else {
t.configSpecs, t.mcpSchemaKnown = registerMCPTools(b.ctx, t.host, t.reg, t.mcp, b.sink)
}
registerResourceTool(t.host, t.reg, cfg, t.mcp, opts.Composition.Live())
// The LSP manager is session-scoped: its servers stop with the controller.
if t.lsp = registerLSP(t.reg, cfg, root); t.lsp != nil {
prev, mgr := b.cleanup, t.lsp
b.cleanup = func() { prev(); mgr.Close() }
}
if proxy := t.env.egress; proxy != nil {
prev := b.cleanup
b.cleanup = func() { prev(); _ = proxy.Close() }
}
b.cleanup = closeAfter(b.cleanup, b.trace)
return nil
}
+9 -4
View File
@@ -18,6 +18,7 @@ import (
"reasonix/internal/ext/hook"
"reasonix/internal/ext/modhost"
"reasonix/internal/ext/plugin"
"reasonix/internal/ext/pluginpkg"
"reasonix/internal/ext/skill"
"reasonix/internal/platform/computer"
"reasonix/internal/session/control"
@@ -181,7 +182,7 @@ func preflightExtensionRuntimes(ctx context.Context, home string, ext extensionB
if selectErr != nil {
return nil, selectErr
}
if len(packages) == 0 {
if len(packages) == 0 && !binding.Live() {
// No v2 runtime packages: surface the installed-state warnings exactly
// as the in-assembly StartPackages call did, and take the untouched
// pre-sidecar path (no processes, no Manager).
@@ -200,7 +201,11 @@ func preflightExtensionRuntimes(ctx context.Context, home string, ext extensionB
var mgr *sidecar.Manager
var warnings []string
var err error
if binding != nil {
if binding.Live() {
mgr, warnings, err = sidecar.StartLivePackages(ctx, home, packages, ext.session, ui, func(current []pluginpkg.InstalledPackage) ([]pluginpkg.InstalledPackage, error) {
return liveCompositionPackages(current, binding)
})
} else if binding != nil {
mgr, warnings, err = sidecar.StartResolvedPackages(ctx, packages, ext.session, ui)
} else {
mgr, warnings, err = startExtensionPackages(ctx, home, ext.session, ui, prev, plan)
@@ -214,7 +219,7 @@ func preflightExtensionRuntimes(ctx context.Context, home string, ext extensionB
// started. The call site treats *RequiredStartError as fatal.
return nil, err
}
if len(mgr.Clients()) == 0 {
if len(mgr.Clients()) == 0 && len(mgr.DeferredPackages()) == 0 && !binding.Live() {
// Every package was optional and failed: retire the empty Manager and
// take the pre-sidecar path with warnings already surfaced.
_ = mgr.Close()
@@ -272,7 +277,7 @@ func assembleLegacySnapshot(ctx context.Context, in legacyAssembly, generation u
// and replacements, then broadcasts system_prompt.build with the final
// prompt so observers see exactly what froze.
var postFreeze func(snap *extension.RuntimeSnapshot)
if mgr != nil && len(mgr.Clients()) > 0 {
if mgr != nil && (len(mgr.Clients()) > 0 || mgr.OnDemand()) {
managed := mgr
bindExtensionUI(ext.ui, managed, ext.warn)
sidecarContribs := managed.Contributions()
+41 -10
View File
@@ -45,7 +45,13 @@ func loadHooks(opts Options, roots config.Roots, root string, shell sandbox.Shel
sink.Emit(projectHooksHeldEvent(held))
}
}
return resolved, hook.NewRunner(resolved, root, hook.NewDefaultSpawner(hookRuntime(shell)), func(n hook.Notice) { sink.Emit(hookNoticeEvent(n)) }), nil
runner := hook.NewRunner(resolved, root, hook.NewDefaultSpawner(hookRuntime(shell)), func(n hook.Notice) { sink.Emit(hookNoticeEvent(n)) })
if opts.Composition.Live() && opts.Observe == nil {
runner.ConfigureSource(func() []hook.ResolvedHook {
return hook.CompositionHooks(hook.Load(hook.LoadOptions{ProjectRoot: root, ReasonixHomeDir: roots.Home()}), opts.Composition.Resolved)
})
}
return resolved, runner, nil
}
// NewUserHookRunner resolves only the hooks the user configured under the
@@ -95,14 +101,23 @@ func registerSessionTools(reg *tool.Registry, set ablation.Set, roots config.Roo
}
func loadCommands(opts Options, root string) []command.Command {
if opts.Composition != nil {
if opts.Composition != nil && !opts.Composition.Live() {
cmds, _ := resource[command.Command](opts.Composition, "command")
return cmds
}
if opts.ReuseAssembly != nil && shouldReuseDiscovery(opts.PreviousPlan) {
return opts.ReuseAssembly.Commands
}
cmds, _ := command.LoadRoots(config.CommandRootsForRoot(root)...)
cmds, _ := command.LoadRoots(opts.roots().CommandRootsForRoot(root)...)
if opts.Composition.Live() {
filtered := []command.Command{}
for _, cmd := range cmds {
if opts.Composition.Resolved.Commands.Allows(cmd.Name) && (cmd.Plugin == "" || opts.Composition.Resolved.Extensions.Allows(cmd.Plugin)) {
filtered = append(filtered, cmd)
}
}
return filtered
}
return cmds
}
@@ -117,14 +132,30 @@ type skillRunners struct {
// tools and their slash entries share one switch, since implicit invocation
// decides whether the model sees skills at all; the slash tool goes last so
// it carries every entry.
func registerSkillTools(reg *tool.Registry, set ablation.Set, store *skill.Store, implicit bool, runners skillRunners, cmds []command.Command) {
var entries []command.SlashEntry
func registerSkillTools(reg *tool.Registry, set ablation.Set, store *skill.Store, implicit bool, runners skillRunners, cmds []command.Command, live func() []command.Command) {
if implicit {
reg.Add(skill.NewReadOnlySkillTool(store, gateSubagentArm(set, runners.readOnly), runners.profile))
reg.Add(skill.NewRunSkillTool(store, gateSubagentArm(set, runners.run), runners.profile))
reg.Add(skill.NewReadSkillTool(store))
reg.Add(skill.NewInstallSkillTool(store, nil))
addTools(reg, builtinSubagentTools(set, store, runners.run, runners.profile))
}
var gate func() func(string) error
if implicit {
gate = store.ModelGate()
}
if live != nil {
reg.Add(command.NewLiveSlashCommandTool(func() ([]command.SlashEntry, error) {
return compositionSlashEntries(store, implicit, live()), nil
}, gate))
} else {
reg.Add(command.NewSlashCommandTool(compositionSlashEntries(store, implicit, cmds), gate))
}
}
func compositionSlashEntries(store *skill.Store, implicit bool, cmds []command.Command) []command.SlashEntry {
var entries []command.SlashEntry
if implicit {
for _, sk := range skill.ModelInvocable(store.SlashList()) {
name := sk.SlashName()
entries = append(entries, command.SlashEntry{
@@ -154,17 +185,14 @@ func registerSkillTools(reg *tool.Registry, set ablation.Set, store *skill.Store
Render: func(args []string) string { return cmd.Render(args) },
})
}
var gate func() func(string) error
if implicit {
gate = store.ModelGate()
}
reg.Add(command.NewSlashCommandTool(entries, gate))
return entries
}
// capabilitySurface is the session-shared MCP runtime behind use_capability:
// one host and spec set, with a frontend per agent so each keeps its own
// ledger and audit while reusing the processes.
type capabilitySurface struct {
refresh func()
specs []plugin.Spec
runtime *usecap.MCPCapabilityRuntime
ledger *capability.Ledger
@@ -187,6 +215,9 @@ func newCapabilitySurface(ctx context.Context, cfg *config.Config, root string,
})
// AllContractEntries: use_capability also dispatches tools the provider never sees.
c.catalog = func() capability.Catalog {
if c.refresh != nil {
c.refresh()
}
conn := map[string]bool{}
failedNow := map[string]string{}
if host != nil {
@@ -72,6 +72,8 @@ type Resource struct {
}
type Binding struct {
FormatVersion int `json:"formatVersion,omitempty"`
ResourceMode string `json:"resourceMode,omitempty"`
Resolved Definition `json:"resolved"`
Started bool `json:"started,omitempty"`
Version Version `json:"version"`
@@ -95,6 +97,9 @@ func (b *Binding) Validate() error {
if b == nil {
return ErrInvalid
}
if err := b.validateResourceFormat(); err != nil {
return err
}
if err := b.Version.Definition.Validate(); err != nil {
return err
}
@@ -0,0 +1,48 @@
package agentcomposition
import (
"fmt"
"slices"
)
const LiveBindingVersion = 2
const LiveResources = "live"
func (b *Binding) Live() bool {
return b != nil && b.FormatVersion == LiveBindingVersion && b.ResourceMode == LiveResources
}
func (b *Binding) validateResourceFormat() error {
if b.FormatVersion == 0 && b.ResourceMode == "" {
return nil
}
if !b.Live() || len(b.Resources) != 0 {
return fmt.Errorf("%w: unsupported binding resource format", ErrInvalid)
}
return nil
}
func (s Selection) Allows(id string) bool {
return s.Mode == Inherit || s.Mode == Select && slices.ContainsFunc(s.Members, func(m Module) bool { return m.ID == id })
}
type Summary struct {
ID string `json:"id"`
Name string `json:"name"`
Revision string `json:"revision"`
Digest string `json:"digest"`
Started bool `json:"started"`
FormatVersion int `json:"formatVersion,omitempty"`
ResourceMode string `json:"resourceMode,omitempty"`
}
func (b *Binding) Summary() *Summary {
if b == nil {
return nil
}
return &Summary{
ID: b.Version.Definition.ID, Name: b.Version.Definition.Name,
Revision: b.Version.Revision, Digest: b.Digest, Started: b.Started,
FormatVersion: b.FormatVersion, ResourceMode: b.ResourceMode,
}
}
@@ -0,0 +1,35 @@
package agentcomposition
import (
"encoding/json"
"testing"
)
func TestBindingResourceFormats(t *testing.T) {
d := Builtins()[0]
for _, tc := range []struct {
name string
extra string
valid bool
}{
{"legacy", "", true},
{"live", `,"formatVersion":2,"resourceMode":"live"`, true},
{"unknown", `,"formatVersion":99,"resourceMode":"live"`, false},
{"missing_mode", `,"formatVersion":2`, false},
{"missing_version", `,"resourceMode":"live"`, false},
{"unknown_mode", `,"formatVersion":2,"resourceMode":"other"`, false},
} {
t.Run(tc.name, func(t *testing.T) {
b := Binding{Version: Version{Definition: d, Revision: Revision(d)}, Resolved: d, Resources: []Resource{}}
raw, _ := json.Marshal(b)
raw = append(raw[:len(raw)-1], []byte(tc.extra+"}")...)
if err := json.Unmarshal(raw, &b); err != nil {
t.Fatal(err)
}
b.Seal()
if err := b.Validate(); (err == nil) != tc.valid {
t.Fatalf("valid = %v, error = %v", tc.valid, err)
}
})
}
}
@@ -2,6 +2,19 @@ package tool
import "maps"
// BindMCPRuntime applies execution authority to existing and future adapters.
func (r *Registry) BindMCPRuntime(bind func(Tool) Tool) {
r.mu.Lock()
r.bindMCP = bind
held := maps.Clone(r.tools)
r.mu.Unlock()
for _, t := range held {
if _, ok := t.(MCPMetadata); ok {
r.Add(t)
}
}
}
// Restrict fixes the ceiling on what the registry may hold. Tools already
// registered that admit rejects are removed, and every later Add is checked
// against it. admit runs outside the registry lock: a lazy tool's own methods
+5 -1
View File
@@ -391,6 +391,7 @@ type Registry struct {
// admit, when non-nil, is the ceiling on what this registry may ever hold:
// Add refuses every tool it rejects, so no later registration path can widen it.
admit func(Tool) bool
bindMCP func(Tool) Tool
schemaRev atomic.Uint64
}
@@ -482,11 +483,14 @@ func (r *Registry) isProviderVisibleLocked(name string) bool {
// (called every turn) reuses the result instead of re-marshaling.
func (r *Registry) Add(t Tool) {
r.mu.RLock()
admit := r.admit
admit, bindMCP := r.admit, r.bindMCP
r.mu.RUnlock()
if admit != nil && !admit(t) {
return
}
if _, mcp := t.(MCPMetadata); mcp && bindMCP != nil {
t = bindMCP(t)
}
r.mu.Lock()
defer r.mu.Unlock()
+13 -1
View File
@@ -34,11 +34,16 @@ type SlashEntry struct {
// and acts on within the same turn — mirroring what typing "/name" does for a
// human. Calling with no name (or "list") returns the available commands.
type slashCommandTool struct {
source func() ([]SlashEntry, error)
gate func() func(name string) error // one snapshot per call; nil admits everything
entries map[string]SlashEntry
names []string // sorted, for a stable listing
}
func NewLiveSlashCommandTool(source func() ([]SlashEntry, error), gate func() func(name string) error) tool.Tool {
return &slashCommandTool{source: source, gate: gate}
}
// NewSlashCommandTool builds the tool from the invocable entries (custom commands
// + skills, adapted by the caller). A later entry wins on a name clash, matching
// the prompt's command>skill precedence when the caller orders them that way.
@@ -80,7 +85,14 @@ func (*slashCommandTool) Schema() json.RawMessage {
}`)
}
func (t *slashCommandTool) Execute(_ context.Context, raw json.RawMessage) (string, error) {
func (t *slashCommandTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
if t.source != nil {
entries, err := t.source()
if err != nil {
return "", err
}
return NewSlashCommandTool(entries, t.gate).Execute(ctx, raw)
}
var p struct {
Command string `json:"command"`
Arguments string `json:"arguments"`
+330
View File
@@ -0,0 +1,330 @@
package sidecar
import (
"context"
"fmt"
"sort"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/ext/extension/protocol"
"reasonix/internal/ext/pluginpkg"
)
type lazyPackages struct {
resolve func([]pluginpkg.InstalledPackage) ([]pluginpkg.InstalledPackage, error)
active map[string]string
calls map[*Client]int
retired map[*Client]bool
life context.Context
cancel context.CancelFunc
home string
session protocol.SessionContext
ui UIHandler
packages map[string]pluginpkg.InstalledPackage
pending map[string]*lazyStart
}
type lazyStart struct {
done chan struct{}
client *Client
err error
}
func (m *Manager) OnDemand() bool {
return m != nil && m.lazy != nil
}
func toolOnlyPackage(p pluginpkg.InstalledPackage) bool {
rt := p.Package.Manifest.Runtime
if rt == nil || rt.Required || len(rt.Intercepts) != 0 || len(rt.Replaces) != 0 {
return false
}
for _, c := range rt.Capabilities {
if c != "tools" {
return false
}
}
for _, c := range p.Package.Manifest.Provides {
if c.Kind != "tool" {
return false
}
}
return len(rt.Tools) > 0 || rt.DynamicTools
}
func StartLivePackages(ctx context.Context, home string, packages []pluginpkg.InstalledPackage, session protocol.SessionContext, ui UIHandler, resolve ...func([]pluginpkg.InstalledPackage) ([]pluginpkg.InstalledPackage, error)) (*Manager, []string, error) {
life, cancel := context.WithCancel(ctx)
lazy := &lazyPackages{life: life, cancel: cancel, home: home, session: session, ui: ui,
packages: map[string]pluginpkg.InstalledPackage{}, pending: map[string]*lazyStart{}, active: map[string]string{},
calls: map[*Client]int{}, retired: map[*Client]bool{}}
if len(resolve) > 0 {
lazy.resolve = resolve[0]
}
var eager []pluginpkg.InstalledPackage
for _, p := range packages {
if toolOnlyPackage(p) {
lazy.packages[p.Installed.Name] = p
} else {
eager = append(eager, p)
}
}
startup, stop := context.WithTimeout(life, packageStartupBudget)
defer stop()
m, warnings, err := startLoadedPackages(startup, eager, session, ui, StartClient)
if err != nil {
cancel()
return m, warnings, err
}
m.lazy = lazy
for _, p := range eager {
lazy.active[p.Installed.Name] = livePackageKey(p)
}
return m, warnings, nil
}
func (m *Manager) DeferredPackages() []pluginpkg.InstalledPackage {
if m == nil {
return nil
}
if m.lazy != nil && m.lazy.resolve != nil {
packages, _ := LoadRuntimePackagesFor(m.lazy.home, m.lazy.session.WorkspaceRoot)
packages, err := m.lazy.resolve(packages)
if err != nil {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return nil
}
var out []pluginpkg.InstalledPackage
for _, p := range packages {
if toolOnlyPackage(p) {
m.lazy.packages[p.Installed.Name] = p
out = append(out, p)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Installed.Name < out[j].Installed.Name })
return out
}
m.mu.Lock()
defer m.mu.Unlock()
var out []pluginpkg.InstalledPackage
if m.lazy != nil && !m.closed {
for _, p := range m.lazy.packages {
out = append(out, p)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Installed.Name < out[j].Installed.Name })
return out
}
// EnsureReady coalesces activation and keeps process lifetime with the manager.
func (m *Manager) EnsureReady(ctx context.Context, name string) (*Client, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return nil, fmt.Errorf("extension manager closed")
}
lazy := m.lazy
if lazy == nil {
client := m.clients[name]
m.mu.Unlock()
if client == nil || client.Exited() {
return nil, fmt.Errorf("extension %s is unavailable", name)
}
return client, nil
}
_, deferred := lazy.packages[name]
m.mu.Unlock()
packages, _ := LoadRuntimePackagesFor(lazy.home, lazy.session.WorkspaceRoot)
if lazy.resolve != nil {
var err error
packages, err = lazy.resolve(packages)
if err != nil {
return nil, err
}
}
var current *pluginpkg.InstalledPackage
for _, p := range packages {
if p.Installed.Name == name {
current = &p
break
}
}
if current == nil || deferred && !toolOnlyPackage(*current) {
m.retireLiveClient(name)
return nil, fmt.Errorf("extension %s was removed, disabled or requires a new runtime", name)
}
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return nil, fmt.Errorf("extension manager closed")
}
if client := m.clients[name]; client != nil {
if !client.Exited() && lazy.active[name] == livePackageKey(*current) {
m.mu.Unlock()
return client, nil
}
if !deferred {
m.mu.Unlock()
return nil, fmt.Errorf("extension %s changed its startup contract; reload the runtime", name)
}
delete(m.clients, name)
closeNow := lazy.calls[client] == 0
if !closeNow {
lazy.retired[client] = true
}
m.mu.Unlock()
if closeNow {
_ = client.Close()
}
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return nil, fmt.Errorf("extension manager closed")
}
if m.clients[name] != nil {
m.mu.Unlock()
return m.EnsureReady(ctx, name)
}
}
if !deferred {
m.mu.Unlock()
return nil, fmt.Errorf("extension %s requires runtime initialization", name)
}
if err := ctx.Err(); err != nil {
m.mu.Unlock()
return nil, err
}
start := lazy.pending[name]
if start == nil {
start = &lazyStart{done: make(chan struct{})}
lazy.pending[name] = start
go m.startDeferred(*current, lazy, start)
}
m.mu.Unlock()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-start.done:
return start.client, start.err
}
}
func (m *Manager) retireLiveClient(name string) {
m.mu.Lock()
client := m.clients[name]
delete(m.clients, name)
closeNow := client != nil && m.lazy.calls[client] == 0
if client != nil && !closeNow {
m.lazy.retired[client] = true
}
m.mu.Unlock()
if closeNow {
_ = client.Close()
}
}
func (m *Manager) startDeferred(p pluginpkg.InstalledPackage, lazy *lazyPackages, start *lazyStart) {
ctx, cancel := context.WithTimeout(lazy.life, packageStartupBudget)
defer cancel()
client, err := startOne(ctx, p, lazy.session, lazy.ui)
m.mu.Lock()
closed := m.closed
if closed {
err = context.Canceled
} else if err == nil {
m.clients[p.Installed.Name] = client
lazy.active[p.Installed.Name] = livePackageKey(p)
}
delete(lazy.pending, p.Installed.Name)
m.mu.Unlock()
if closed && client != nil {
_ = client.Close()
}
start.client, start.err = client, err
close(start.done)
}
func livePackageKey(p pluginpkg.InstalledPackage) string {
return agentcomposition.Digest(struct {
Installed pluginpkg.InstalledPlugin
Manifest pluginpkg.Manifest
}{p.Installed, p.Package.Manifest})
}
func (m *Manager) WithReady(ctx context.Context, name string, call func(context.Context, *Client) error) error {
for {
client, err := m.EnsureReady(ctx, name)
if err != nil {
return err
}
if m.lazy == nil {
return call(ctx, client)
}
if err := m.validateLiveCall(name); err != nil {
return err
}
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return context.Canceled
}
if m.clients[name] != client {
m.mu.Unlock()
continue
}
m.lazy.calls[client]++
m.mu.Unlock()
return m.callWithLiveLease(ctx, client, call)
}
}
func (m *Manager) callWithLiveLease(ctx context.Context, client *Client, call func(context.Context, *Client) error) error {
defer m.releaseLiveCall(client)
callCtx, cancel := context.WithCancel(ctx)
defer cancel()
stop := context.AfterFunc(m.lazy.life, cancel)
defer stop()
if err := callCtx.Err(); err != nil {
return err
}
return call(callCtx, client)
}
func (m *Manager) releaseLiveCall(client *Client) {
m.mu.Lock()
m.lazy.calls[client]--
closeNow := m.lazy.calls[client] == 0 && m.lazy.retired[client]
if m.lazy.calls[client] == 0 {
delete(m.lazy.calls, client)
delete(m.lazy.retired, client)
}
m.mu.Unlock()
if closeNow {
_ = client.Close()
}
}
func (m *Manager) validateLiveCall(name string) error {
packages, _ := LoadRuntimePackagesFor(m.lazy.home, m.lazy.session.WorkspaceRoot)
if m.lazy.resolve != nil {
var err error
packages, err = m.lazy.resolve(packages)
if err != nil {
return err
}
}
m.mu.Lock()
defer m.mu.Unlock()
for _, p := range packages {
if p.Installed.Name == name && m.lazy.active[name] == livePackageKey(p) && !m.closed {
return nil
}
}
return fmt.Errorf("extension %s changed or was disabled before dispatch", name)
}
@@ -0,0 +1,26 @@
package sidecar
import (
"testing"
"reasonix/internal/ext/extension/protocol"
"reasonix/internal/ext/pluginpkg"
)
func TestToolOnlyRuntimeIsDeferredWithoutCopyingPackage(t *testing.T) {
p := pluginpkg.InstalledPackage{}
p.Installed.Name = "lazy-tools"
p.Package.Root = "/nonexistent-package-not-read-at-selection"
p.Package.Manifest.Runtime = &pluginpkg.RuntimeSpec{
Command: "/must-not-start",
Tools: []pluginpkg.RuntimeTool{{Name: "echo"}},
}
m, _, err := StartLivePackages(t.Context(), t.TempDir(), []pluginpkg.InstalledPackage{p}, protocol.SessionContext{}, nil)
if err != nil {
t.Fatal(err)
}
defer m.Close()
if len(m.Clients()) != 0 || len(m.DeferredPackages()) != 1 || len(m.privateRoots) != 0 {
t.Fatal("tool-only package was launched, copied or omitted")
}
}
@@ -0,0 +1,48 @@
package sidecar
import (
"fmt"
"slices"
"reasonix/internal/contract/agentcomposition"
)
func (m *Manager) ToolClients() []*Client {
clients := m.Clients()
if m.lazy != nil {
clients = slices.DeleteFunc(clients, func(c *Client) bool {
return m.validateLiveCall(c.PluginID()) != nil
})
}
return clients
}
func (m *Manager) ValidateLiveStartup() error {
if m == nil || m.lazy == nil || m.lazy.resolve == nil {
return nil
}
packages, _ := LoadRuntimePackagesFor(m.lazy.home, m.lazy.session.WorkspaceRoot)
packages, err := m.lazy.resolve(packages)
if err != nil {
return err
}
current := map[string]string{}
for _, p := range packages {
if !toolOnlyPackage(p) {
current[p.Installed.Name] = livePackageKey(p)
}
}
m.mu.Lock()
defer m.mu.Unlock()
for name, key := range current {
if m.lazy.active[name] != key {
return fmt.Errorf("%w: extension %s changed its startup contract; start a new session", agentcomposition.ErrUnavailable, name)
}
}
for name := range m.lazy.active {
if _, deferred := m.lazy.packages[name]; !deferred && current[name] == "" {
return fmt.Errorf("%w: required runtime extension %s is no longer available", agentcomposition.ErrUnavailable, name)
}
}
return nil
}
+17
View File
@@ -109,6 +109,7 @@ func OnCrash(f func(pluginID string, err error)) {
// alone. It implements io.Closer so the kernel's RuntimeSet can retire it
// with its controller generation.
type Manager struct {
lazy *lazyPackages
mu sync.Mutex
clients map[string]*Client
closed bool
@@ -232,16 +233,32 @@ func (m *Manager) Close() error {
return nil
}
m.closed = true
var pending []<-chan struct{}
if m.lazy != nil {
m.lazy.cancel()
for _, start := range m.lazy.pending {
pending = append(pending, start.done)
}
}
clients := make([]*Client, 0, len(m.clients))
for _, client := range m.clients {
clients = append(clients, client)
}
if m.lazy != nil {
for client := range m.lazy.retired {
clients = append(clients, client)
}
m.lazy.retired = nil
}
m.clients = nil
m.planAdopted = nil
privateRoots := m.privateRoots
m.privateRoots = nil
m.mu.Unlock()
for _, done := range pending {
<-done
}
var wg sync.WaitGroup
for _, client := range clients {
wg.Add(1)
+43
View File
@@ -0,0 +1,43 @@
package hook
import (
"fmt"
"strings"
"reasonix/internal/contract/agentcomposition"
)
func CompositionID(h ResolvedHook) string {
s := h.Snapshot()
s.Hook.Env = nil
s.Hook.Timeout = 0
s.Hook.Description = ""
s.Approval = nil
return "hook:" + agentcomposition.Digest(s)
}
func ResolveCompositionSelection(selection agentcomposition.Selection, hooks []ResolvedHook) agentcomposition.Selection {
out := agentcomposition.Clone(selection)
for i, m := range out.Members {
if strings.HasPrefix(m.ID, "hook:") {
continue
}
for index, h := range hooks {
if m.ID == fmt.Sprintf("%s:%s:%d", h.Scope, h.Event, index) {
out.Members[i].ID = CompositionID(h)
break
}
}
}
return out
}
func CompositionHooks(hooks []ResolvedHook, d agentcomposition.Definition) []ResolvedHook {
out := []ResolvedHook{}
for _, h := range hooks {
if h.IsPolicyGuard() || d.Hooks.Allows(CompositionID(h)) && (h.Scope != ScopePlugin || d.Extensions.Allows(h.Env["REASONIX_PLUGIN_NAME"])) {
out = append(out, h)
}
}
return out
}
+23
View File
@@ -0,0 +1,23 @@
package hook
import (
"testing"
"reasonix/internal/contract/agentcomposition"
)
func TestCompositionHookSelectionSurvivesReorderWithoutWidening(t *testing.T) {
first := ResolvedHook{HookConfig: HookConfig{Command: "first"}, Event: SessionStart, Scope: ScopeGlobal, Source: "/settings"}
second := ResolvedHook{HookConfig: HookConfig{Command: "second"}, Event: SessionStart, Scope: ScopeGlobal, Source: "/settings"}
selected := ResolveCompositionSelection(agentcomposition.Selection{Mode: agentcomposition.Select, Members: []agentcomposition.Module{{ID: "global:SessionStart:0"}}}, []ResolvedHook{first, second})
d := agentcomposition.Builtins()[0]
d.Hooks = selected
got := CompositionHooks([]ResolvedHook{second, first}, d)
if len(got) != 1 || got[0].Command != "first" {
t.Fatalf("reordering changed the grant: %+v", got)
}
first.Command = "replacement"
if got := CompositionHooks([]ResolvedHook{second, first}, d); len(got) != 0 {
t.Fatalf("changed launch inherited an old grant: %+v", got)
}
}
+15 -4
View File
@@ -29,8 +29,9 @@ type Runner struct {
// hookSet is what a session and every runner derived from it with ForRole
// fire, so an edit reaches them all and a repeat is judged once for the user.
type hookSet struct {
mu sync.RWMutex
hooks []ResolvedHook
source func() []ResolvedHook
mu sync.RWMutex
hooks []ResolvedHook
// lastOutcome holds the message each hook reported last, so a repeat of the
// same one is not surfaced again. Keyed by hook, so it is bounded by the
// configured hook count rather than by how often they run.
@@ -116,8 +117,18 @@ func (r *Runner) snapshot() []ResolvedHook {
return nil
}
r.set.mu.RLock()
defer r.set.mu.RUnlock()
return r.set.hooks
source, hooks := r.set.source, r.set.hooks
r.set.mu.RUnlock()
if source != nil {
return source()
}
return hooks
}
func (r *Runner) ConfigureSource(source func() []ResolvedHook) {
r.set.mu.Lock()
defer r.set.mu.Unlock()
r.set.source = source
}
// Enabled reports whether any hooks are configured.
+143
View File
@@ -0,0 +1,143 @@
package skill
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"reasonix/internal/base/fileutil"
fileencoding "reasonix/internal/base/fileutil/encoding"
"reasonix/internal/base/secrets"
)
const maxSupplementBytes = 128 << 20
const maxSupplementFiles = 20000
func completeConfinedSkill(sk Skill) (string, error) {
path, err := fileutil.ResolveExistingPath(sk.Path)
if err != nil {
return "", fmt.Errorf("%w: skill %s source is unavailable", ErrInvocationUnavailable, sk.SlashName())
}
root, err := os.OpenRoot(filepath.Dir(path))
if err != nil {
return "", fmt.Errorf("%w: skill %s source is unavailable", ErrInvocationUnavailable, sk.SlashName())
}
defer root.Close()
body, err := confinedSupplementBody(root, sk)
if err != nil {
return "", fmt.Errorf("%w: skill %s: %w", ErrInvocationUnavailable, sk.SlashName(), err)
}
return body, nil
}
func confinedSupplementBody(root *os.Root, sk Skill) (string, error) {
var body strings.Builder
body.WriteString(sk.Body)
for _, folder := range []string{"references", "scripts"} {
directory, err := confinedSupplementPath(root, folder)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return "", fmt.Errorf("cannot open %s within the skill directory", folder)
}
dir, err := root.Open(directory)
if err != nil {
return "", fmt.Errorf("cannot open %s within the skill directory", folder)
}
entries, err := dir.ReadDir(maxSupplementFiles + 1)
_ = dir.Close()
if err != nil && !errors.Is(err, io.EOF) || len(entries) > maxSupplementFiles {
return "", fmt.Errorf("cannot list %s within the skill limit", folder)
}
names := []string{}
for _, entry := range entries {
if entry.IsDir() {
continue
}
if folder == "references" && !strings.EqualFold(filepath.Ext(entry.Name()), ".md") || folder == "scripts" && !isScriptExt(filepath.Ext(entry.Name())) {
continue
}
names = append(names, entry.Name())
}
slices.Sort(names)
for i, name := range names {
rel := filepath.Join(folder, name)
resolved, err := confinedSupplementPath(root, rel)
if err != nil {
return "", fmt.Errorf("resource %s leaves the allowed skill source", rel)
}
info, err := root.Stat(resolved)
if err != nil || !info.Mode().IsRegular() || secrets.SensitiveFileName(name) {
return "", fmt.Errorf("resource %s is not an allowed skill file", rel)
}
if folder == "references" {
data, err := readConfinedSupplement(root, resolved, maxSupplementBytes-body.Len())
if err != nil {
return "", err
}
if text := strings.TrimSpace(string(data)); text != "" {
body.WriteString("\n\n## Reference: " + strings.TrimSuffix(name, filepath.Ext(name)) + "\n\n" + text)
}
} else {
if i == 0 {
body.WriteString("\n\n## Scripts\n\nRun a listed script with bash using the exact path shown below; quote the path if it contains spaces.\n\n")
}
body.WriteString("- `" + filepath.Join(filepath.Dir(sk.Path), rel) + "`\n")
}
if body.Len() > maxSupplementBytes {
return "", fmt.Errorf("skill supplement limit exceeded")
}
}
}
return body.String(), nil
}
func confinedSupplementPath(root *os.Root, path string) (string, error) {
resolved, err := fileutil.ResolveExistingPath(filepath.Join(root.Name(), path))
if err != nil {
return "", err
}
rel, err := filepath.Rel(root.Name(), resolved)
if err != nil || !filepath.IsLocal(rel) {
return "", ErrInvocationUnavailable
}
for component := range strings.SplitSeq(filepath.ToSlash(rel), "/") {
if secrets.SensitiveFileName(component) || component == ".git" || component == ".hg" || component == ".svn" {
return "", ErrInvocationUnavailable
}
}
return rel, nil
}
func readConfinedSupplement(root *os.Root, path string, limit int) ([]byte, error) {
expected, err := root.Lstat(path)
if err != nil || !expected.Mode().IsRegular() {
return nil, fmt.Errorf("resource %s changed before reading", path)
}
file, err := root.Open(path)
if err != nil {
return nil, fmt.Errorf("cannot read resource %s within the skill directory", path)
}
defer file.Close()
actual, err := file.Stat()
if err != nil || !os.SameFile(expected, actual) {
return nil, fmt.Errorf("resource %s changed while opening", path)
}
if limit < 0 {
return nil, fmt.Errorf("skill supplement limit exceeded")
}
data, err := io.ReadAll(io.LimitReader(file, int64(limit)+1))
if err != nil || len(data) > limit {
return nil, fmt.Errorf("cannot read resource %s within the skill limit", path)
}
data = fileencoding.DecodeToUTF8(data)
if len(data) > limit {
return nil, fmt.Errorf("decoded skill supplement limit exceeded")
}
return data, nil
}
@@ -0,0 +1,42 @@
package skill
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLiveSkillReadsOnlyConfinedSupplements(t *testing.T) {
for _, outside := range []bool{false, true} {
t.Run(map[bool]string{false: "contained", true: "escaped"}[outside], func(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "references"), 0o700); err != nil {
t.Fatal(err)
}
skillPath := filepath.Join(root, "SKILL.md")
if err := os.WriteFile(skillPath, []byte("BODY"), 0o600); err != nil {
t.Fatal(err)
}
target := filepath.Join(root, "guide.md")
if outside {
target = filepath.Join(t.TempDir(), "guide.md")
}
if err := os.WriteFile(target, []byte("REFERENCE BODY"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, filepath.Join(root, "references", "guide.md")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
sk := (Skill{Name: "fixture", Path: skillPath, Body: "BODY", supplementsDeferred: true, confinedSupplements: true}).Complete()
if outside {
if !errors.Is(sk.completionError, ErrInvocationUnavailable) || strings.Contains(sk.Body, "REFERENCE BODY") {
t.Fatalf("escaped reference read: %q %v", sk.Body, sk.completionError)
}
} else if sk.completionError != nil || !strings.Contains(sk.Body, "REFERENCE BODY") {
t.Fatalf("contained reference failed: %q %v", sk.Body, sk.completionError)
}
})
}
}
@@ -0,0 +1,49 @@
package skill
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestWindowsLiveSkillJunctions(t *testing.T) {
for _, outside := range []bool{false, true} {
t.Run(map[bool]string{false: "contained", true: "escaped"}[outside], func(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "data")
if outside {
target = filepath.Join(t.TempDir(), "data")
}
if err := os.Mkdir(target, 0o700); err != nil {
t.Fatal(err)
}
for path, body := range map[string]string{
filepath.Join(root, "SKILL.md"): "BODY", filepath.Join(target, "guide.md"): "REFERENCE BODY",
} {
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
linkedRoot := filepath.Join(t.TempDir(), "skill")
for link, source := range map[string]string{
filepath.Join(root, "references"): target,
linkedRoot: root,
} {
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, source).CombinedOutput(); err != nil {
t.Fatalf("create junction: %v: %s", err, out)
}
}
sk := (Skill{Name: "fixture", Path: filepath.Join(linkedRoot, "SKILL.md"), Body: "BODY", supplementsDeferred: true, confinedSupplements: true}).Complete()
if outside {
if !errors.Is(sk.completionError, ErrInvocationUnavailable) || strings.Contains(sk.Body, "REFERENCE BODY") {
t.Fatalf("escaped reference read: %q %v", sk.Body, sk.completionError)
}
} else if sk.completionError != nil || !strings.Contains(sk.Body, "REFERENCE BODY") {
t.Fatalf("contained reference failed: %q %v", sk.Body, sk.completionError)
}
})
}
}
+27 -1
View File
@@ -1,6 +1,9 @@
package skill
import "reasonix/internal/contract/config"
import (
"reasonix/internal/contract/config"
"slices"
)
func (s *Store) discover(scan func(discoveryRoot) []Skill) []Skill {
if s != nil && s.snapshot != nil {
@@ -9,6 +12,14 @@ func (s *Store) discover(scan func(discoveryRoot) []Skill) []Skill {
if s == nil || s.disableDiscovery {
return nil
}
if s.discoverySource != nil {
current := s.discoverySource()
if current == nil {
return nil
}
current.admits = s.admits
return current.discover(current.discoverRoot)
}
var out []Skill
disabled := s.disabledSet()
for _, r := range s.roots() {
@@ -40,5 +51,20 @@ func (s *Store) discover(scan func(discoveryRoot) []Skill) []Skill {
}
}
}
if s.admits != nil {
out = slices.DeleteFunc(out, func(sk Skill) bool { return !s.admits(sk) })
for i := range out {
out[i].confinedSupplements = true
}
}
return out
}
// Restrict applies the session ceiling before any discovery or invocation.
func (s *Store) Restrict(admits func(Skill) bool) {
s.admits = admits
}
func (s *Store) ConfigureDiscoverySource(source func() *Store) {
s.discoverySource = source
}
+5 -1
View File
@@ -128,5 +128,9 @@ func (s *Store) gateSkill(skills []Skill, slashName string) (Skill, error) {
if err := s.ValidateInvocation(sk); err != nil {
return Skill{}, err
}
return s.Prepare(sk), nil
sk = s.Prepare(sk)
if sk.completionError != nil {
return Skill{}, sk.completionError
}
return sk, nil
}
+12 -99
View File
@@ -74,6 +74,8 @@ type Skill struct {
// supplementsDeferred marks a discovered directory-layout skill whose
// references/ and scripts/ are not yet in Body.
supplementsDeferred bool
confinedSupplements bool
completionError error
// SlashPrefix overrides Plugin only for the user-facing invocation name.
// Imported Claude agents use <plugin>:agent so an agent and skill may safely
// share the same upstream name.
@@ -166,6 +168,8 @@ type Options struct {
// Store resolves skills across the configured roots.
type Store struct {
discoverySource func() *Store
admits func(Skill) bool
snapshot *[]Skill
homeDir string
reasonixHomeDir string
@@ -372,12 +376,18 @@ func bindAllowedTools(refs []string, bindings []tool.MCPBinding) []string {
// Skill profiles frontmatter is diagnostic-only: it never blocks invocation.
// Required capabilities still gate execution.
func (s *Store) ValidateInvocation(sk Skill) error {
if sk.completionError != nil {
return sk.completionError
}
if err := sk.modelCallError(); err != nil {
return err
}
if s == nil {
return nil
}
if s.admits != nil && (!s.admits(sk) || s.disabledSet()[config.SkillNameKey(sk.Name)]) {
return fmt.Errorf("%w: skill %q is outside the current grant", ErrInvocationUnavailable, sk.SlashName())
}
if len(sk.Requires) > 0 && s.requiresReady != nil {
if missing := s.requiresReady(sk.Requires); len(missing) > 0 {
return fmt.Errorf("%w: skill %q requires unavailable capabilities: %s", ErrInvocationUnavailable, sk.Name, strings.Join(missing, ", "))
@@ -660,7 +670,8 @@ func (s *Store) Read(name string) (Skill, bool) {
// bare identifiers accepted by Read/run_skill.
func (s *Store) ReadSlash(name string) (Skill, bool) {
sk, ok := ResolveSlashSkill(s.discoveredSkills(), name)
return sk.Complete(), ok
sk = sk.Complete()
return sk, ok && sk.completionError == nil
}
func (s *Store) discoverRoot(r discoveryRoot) []Skill {
@@ -1113,104 +1124,6 @@ func (s *Store) globalSkillsRoot() string {
return filepath.Join(s.homeDir, ".reasonix", SkillsDirname)
}
// Complete returns the skill with its references and scripts folded into Body.
// Discovery leaves them out because a listing needs only the metadata, and
// reading every skill's sibling files per catalog build is what made opening a
// pane scale with the size of the skill library.
func (s Skill) Complete() Skill {
if !s.supplementsDeferred {
return s
}
s.Body = loadBodyWithScripts(s.Path, loadBodyWithReferences(s.Path, s.Body))
s.supplementsDeferred = false
return s
}
// loadBodyWithReferences appends a directory-layout skill's sibling
// references/*.md files to its body (Anthropic Skills compatibility), so depth
// material is available without on-demand resolution. Flat skills have no
// references dir and are returned unchanged.
func loadBodyWithReferences(skillPath, body string) string {
if filepath.Base(skillPath) != SkillFile {
return body
}
refsDir := filepath.Join(filepath.Dir(skillPath), "references")
entries, err := os.ReadDir(refsDir)
if err != nil {
return body
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.EqualFold(filepath.Ext(e.Name()), ".md") {
names = append(names, e.Name())
}
}
if len(names) == 0 {
return body
}
slices.Sort(names)
var b strings.Builder
b.WriteString(body)
for _, n := range names {
content, err := fileencoding.ReadFileUTF8(filepath.Join(refsDir, n))
if err != nil {
continue
}
trimmed := strings.TrimSpace(string(content))
if trimmed == "" {
continue
}
slug := strings.TrimSuffix(n, filepath.Ext(n))
b.WriteString("\n\n## Reference: " + slug + "\n\n" + trimmed)
}
return b.String()
}
// loadBodyWithScripts appends a directory-layout skill's sibling scripts/
// directory listing to the body, so the model knows what scripts are
// available and can run them via bash (inheriting sandbox, gate, hooks).
func loadBodyWithScripts(skillPath, body string) string {
if filepath.Base(skillPath) != SkillFile {
return body
}
scriptsDir := filepath.Join(filepath.Dir(skillPath), "scripts")
entries, err := os.ReadDir(scriptsDir)
if err != nil {
return body
}
var names []string
for _, e := range entries {
// Filter hidden files — bash should not see config dotfiles in scripts/.
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
if !isScriptExt(filepath.Ext(e.Name())) {
continue
}
names = append(names, e.Name())
}
if len(names) == 0 {
return body
}
slices.Sort(names)
var b strings.Builder
b.WriteString(body)
b.WriteString("\n\n## Scripts\n\nRun a listed script with bash using the exact path shown below; quote the path if it contains spaces.\n\n")
for _, n := range names {
b.WriteString("- `" + filepath.Join(scriptsDir, n) + "`\n")
}
return b.String()
}
func isScriptExt(ext string) bool {
switch strings.ToLower(ext) {
case "", ".sh", ".py", ".js", ".ts", ".rb", ".pl", ".php", ".ps1":
return true
default:
return false
}
}
// parseCSVFrontmatter splits simple comma-separated frontmatter values. Full
// YAML lists are intentionally out of scope for the existing frontmatter parser.
func parseCSVFrontmatter(raw string) []string {
+112
View File
@@ -0,0 +1,112 @@
package skill
import (
"os"
"path/filepath"
fileencoding "reasonix/internal/base/fileutil/encoding"
"slices"
"strings"
)
// Complete returns the skill with its references and scripts folded into Body.
// Discovery leaves them out because a listing needs only the metadata, and
// reading every skill's sibling files per catalog build is what made opening a
// pane scale with the size of the skill library.
func (s Skill) Complete() Skill {
if !s.supplementsDeferred {
return s
}
if s.confinedSupplements {
s.Body, s.completionError = completeConfinedSkill(s)
s.supplementsDeferred = false
return s
}
s.Body = loadBodyWithScripts(s.Path, loadBodyWithReferences(s.Path, s.Body))
s.supplementsDeferred = false
return s
}
// loadBodyWithReferences appends a directory-layout skill's sibling
// references/*.md files to its body (Anthropic Skills compatibility), so depth
// material is available without on-demand resolution. Flat skills have no
// references dir and are returned unchanged.
func loadBodyWithReferences(skillPath, body string) string {
if filepath.Base(skillPath) != SkillFile {
return body
}
refsDir := filepath.Join(filepath.Dir(skillPath), "references")
entries, err := os.ReadDir(refsDir)
if err != nil {
return body
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.EqualFold(filepath.Ext(e.Name()), ".md") {
names = append(names, e.Name())
}
}
if len(names) == 0 {
return body
}
slices.Sort(names)
var b strings.Builder
b.WriteString(body)
for _, n := range names {
content, err := fileencoding.ReadFileUTF8(filepath.Join(refsDir, n))
if err != nil {
continue
}
trimmed := strings.TrimSpace(string(content))
if trimmed == "" {
continue
}
slug := strings.TrimSuffix(n, filepath.Ext(n))
b.WriteString("\n\n## Reference: " + slug + "\n\n" + trimmed)
}
return b.String()
}
// loadBodyWithScripts appends a directory-layout skill's sibling scripts/
// directory listing to the body, so the model knows what scripts are
// available and can run them via bash (inheriting sandbox, gate, hooks).
func loadBodyWithScripts(skillPath, body string) string {
if filepath.Base(skillPath) != SkillFile {
return body
}
scriptsDir := filepath.Join(filepath.Dir(skillPath), "scripts")
entries, err := os.ReadDir(scriptsDir)
if err != nil {
return body
}
var names []string
for _, e := range entries {
// Filter hidden files — bash should not see config dotfiles in scripts/.
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
if !isScriptExt(filepath.Ext(e.Name())) {
continue
}
names = append(names, e.Name())
}
if len(names) == 0 {
return body
}
slices.Sort(names)
var b strings.Builder
b.WriteString(body)
b.WriteString("\n\n## Scripts\n\nRun a listed script with bash using the exact path shown below; quote the path if it contains spaces.\n\n")
for _, n := range names {
b.WriteString("- `" + filepath.Join(scriptsDir, n) + "`\n")
}
return b.String()
}
func isScriptExt(ext string) bool {
switch strings.ToLower(ext) {
case "", ".sh", ".py", ".js", ".ts", ".rb", ".pl", ".php", ".ps1":
return true
default:
return false
}
}
+11 -3
View File
@@ -70,9 +70,17 @@ func withCompositionConfig(state SessionConfigState, ctrl acpController) Session
current := "builtin:general"
locked := control.CompositionStarted(ctrl) || ctrl.PendingPrompt() || ctrl.RuntimeStatus().BackgroundJobs > 0
currentName := "Standard"
if b := ctrl.CompositionBinding(); b != nil {
current = b.Version.Definition.ID
currentName = b.Version.Definition.Name
var summary *agentcomposition.Summary
if lightweight, ok := ctrl.(interface {
CompositionSummary() *agentcomposition.Summary
}); ok {
summary = lightweight.CompositionSummary()
} else {
summary = ctrl.CompositionBinding().Summary()
}
if b := summary; b != nil {
current = b.ID
currentName = b.Name
locked = locked || b.Started
}
options := []SessionConfigSelectOption{}
+22 -9
View File
@@ -77,16 +77,25 @@ func agentPresetMutation(r *http.Request) bool {
}
type compositionSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Revision string `json:"revision"`
Digest string `json:"digest"`
Started bool `json:"started"`
Unavailable string `json:"unavailable,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Revision string `json:"revision"`
Digest string `json:"digest"`
Started bool `json:"started"`
Unavailable string `json:"unavailable,omitempty"`
FormatVersion int `json:"formatVersion,omitempty"`
ResourceMode string `json:"resourceMode,omitempty"`
}
func compositionControllerView(ctrl control.SessionAPI) *compositionSummary {
view := compositionView(ctrl.CompositionBinding())
var view *compositionSummary
if lightweight, ok := ctrl.(interface {
CompositionSummary() *agentcomposition.Summary
}); ok {
view = compositionSummaryView(lightweight.CompositionSummary())
} else {
view = compositionView(ctrl.CompositionBinding())
}
if c, ok := ctrl.(*control.Controller); ok && view != nil {
if err := c.CompositionExecutionError(); err != nil {
view.Unavailable = err.Error()
@@ -100,10 +109,14 @@ func compositionLocked(ctrl control.SessionAPI) bool {
}
func compositionView(b *agentcomposition.Binding) *compositionSummary {
return compositionSummaryView(b.Summary())
}
func compositionSummaryView(b *agentcomposition.Summary) *compositionSummary {
if b == nil {
return nil
}
return &compositionSummary{ID: b.Version.Definition.ID, Name: b.Version.Definition.Name, Revision: b.Version.Revision, Digest: b.Digest, Started: b.Started}
return &compositionSummary{ID: b.ID, Name: b.Name, Revision: b.Revision, Digest: b.Digest, Started: b.Started, FormatVersion: b.FormatVersion, ResourceMode: b.ResourceMode}
}
func (s *Server) saveAgentPreset(w http.ResponseWriter, r *http.Request) {
@@ -151,7 +164,7 @@ func (s *Server) selectAgentPreset(w http.ResponseWriter, r *http.Request) {
compositionError(w, err)
return
}
writeJSON(w, compositionView(s.ctl().CompositionBinding()))
writeJSON(w, compositionControllerView(s.ctl()))
}
func (s *Server) exportAgentPreset(w http.ResponseWriter, r *http.Request) {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
)
func (s *Server) newSession(w http.ResponseWriter, r *http.Request) {
if s.ctl().CompositionBinding() == nil {
if compositionControllerView(s.ctl()) == nil {
s.newLegacySession(w, r)
return
}
@@ -0,0 +1,109 @@
package serve
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"reasonix/internal/assembly/boot"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/contract/provider"
"reasonix/internal/session/control"
"reasonix/internal/state/agentpreset"
"reasonix/internal/state/sessionstore"
)
func TestLiveCompositionSwitchResourceCost(t *testing.T) {
if os.Getenv("REASONIX_PRESET_BENCHMARK") != "1" {
t.Skip("opt-in repeated measurement; deterministic resource guards run separately")
}
for _, files := range []int{0, 4096} {
t.Run(fmt.Sprintf("files-%d", files), func(t *testing.T) {
home, root := t.TempDir(), t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Chdir(root)
closeSharedCatalogsOnCleanup(t)
skillRoot := filepath.Join(home, "skills", "templates")
writePluginFile(t, filepath.Join(skillRoot, "SKILL.md"), "---\nname: templates\ndescription: template library\n---\nLoad individual templates when needed.")
writePluginFile(t, filepath.Join(root, "reasonix.toml"), "[codegraph]\nenabled=false\n[environment]\nenabled=false\n")
if _, err := config.RootsForHome(home).ApproveWorkspacePrograms(root); err != nil {
t.Fatal(err)
}
payload := make([]byte, 16<<10)
for i := range files {
writePluginFile(t, filepath.Join(skillRoot, "templates", fmt.Sprintf("%04d.bin", i)), string(payload))
}
d := agentcomposition.Builtins()[0]
d.ID = "user:performance"
if _, err := agentpreset.New(home, root).Save(d, ""); err != nil {
t.Fatal(err)
}
resolver := &provider.StaticResolver{
Descriptors: []provider.Descriptor{{Ref: "neutral/fixture", Model: "fixture", Default: true}},
Providers: map[string]provider.Provider{"neutral/fixture": credentialPreviewProvider{}},
}
for sample := range 20 {
bc := NewBroadcaster()
initial, err := boot.BuildRuntime(t.Context(), boot.Options{Home: home, WorkspaceRoot: root, Model: "neutral/fixture", ProviderResolver: resolver, Sink: bc})
if err != nil {
t.Fatal(err)
}
s := New(initial.Controller, bc, config.ServeConfig{})
s.resolver = resolver
s.AdoptRuntime(initial)
leases := control.NewSessionLeaseKeeper()
s.SetSessionLeases(leases)
for _, phase := range []string{"first-switch", "repeat-switch"} {
runtime.GC()
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
start := time.Now()
if err := s.ctl().SelectAgentComposition(t.Context(), d.ID); err != nil {
t.Fatal(err)
}
elapsed := time.Since(start)
runtime.ReadMemStats(&after)
binding := s.ctl().CompositionBinding()
if !binding.Live() || len(binding.Resources) != 0 {
t.Fatal("switch retained resource payload")
}
data, _ := json.Marshal(binding)
var persisted int64
for _, path := range []string{s.ctl().SessionPath(), sessionstore.SessionEventLogPath(s.ctl().SessionPath())} {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
persisted += info.Size()
}
t.Logf("PRESET_COST files=%d sample=%d phase=%s ms=%.3f allocated_bytes=%d binding_bytes=%d persisted_bytes=%d", files, sample, phase, float64(elapsed)/float64(time.Millisecond), after.TotalAlloc-before.TotalAlloc, len(data), persisted)
if phase == "first-switch" {
if err := s.ctl().SelectAgentComposition(t.Context(), "builtin:general"); err != nil {
t.Fatal(err)
}
}
}
target, ok := s.lastBuild.Assembly.Registry.Get("read_skill")
if !ok {
t.Fatal("skill entry missing")
}
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
start := time.Now()
if _, err := target.Execute(t.Context(), json.RawMessage(`{"name":"templates"}`)); err != nil {
t.Fatal(err)
}
elapsed := time.Since(start)
runtime.ReadMemStats(&after)
t.Logf("PRESET_COST files=%d sample=%d phase=first-use ms=%.3f allocated_bytes=%d", files, sample, float64(elapsed)/float64(time.Millisecond), after.TotalAlloc-before.TotalAlloc)
s.ctl().Close()
leases.Release()
}
})
}
}
@@ -42,7 +42,7 @@ func (s *Server) switchComposition(ctx context.Context, cur *control.Controller,
if err != nil {
return err
}
if b := cur.CompositionBinding(); b != nil && b.Version.Revision == v.Revision {
if b := cur.CompositionSummary(); b != nil && b.Revision == v.Revision {
return nil
}
opts := s.rebuildOptions(cur, currentModelRef(cur))
+46
View File
@@ -1,5 +1,51 @@
package usecap
import (
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/tool"
"reasonix/internal/ext/plugin"
)
func (r *MCPCapabilityRuntime) ConfigureComposition(d agentcomposition.Definition) {
r.dispatchMu.Lock()
defer r.dispatchMu.Unlock()
policy := agentcomposition.Clone(d)
r.composition = &policy
r.mu.Lock()
defer r.mu.Unlock()
for name, server := range r.servers {
if !r.compositionAllows(server.spec) {
delete(r.servers, name)
r.state.clearServer(name)
}
}
}
func (r *MCPCapabilityRuntime) compositionAllows(spec plugin.Spec) bool {
return r.composition == nil || r.composition.MCP.Allows(spec.Name) && (spec.Package == "" || r.composition.Extensions.Allows(spec.Package))
}
func (r *MCPCapabilityRuntime) BindRegisteredTool(target tool.Tool) tool.Tool {
if bound, ok := target.(*runtimeBoundMCPTool); ok && bound.proxy.runtime == r {
return target
}
meta, ok := target.(tool.MCPMetadata)
if !ok {
return target
}
return &runtimeBoundMCPTool{proxy: r.NewFrontend(nil, nil), target: target, server: meta.MCPServerName(), authorized: tool.IsMCPServerAuthorized(target)}
}
func mcpToolMatchesRuntime(target tool.Tool, spec plugin.Spec) bool {
for {
bound, ok := target.(*runtimeBoundMCPTool)
if !ok {
return plugin.MCPToolMatchesSpec(target, spec)
}
target = bound.target
}
}
// FreezeComposition keeps settings edits from widening this session's catalog.
func (r *MCPCapabilityRuntime) FreezeComposition() {
if r == nil {
@@ -0,0 +1,69 @@
package usecap
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/contract/tool"
"reasonix/internal/ext/plugin"
)
func TestLiveCompositionInventoryRefreshKeepsWhitelist(t *testing.T) {
r := NewMCPCapabilityRuntime(t.Context(), plugin.NewHost(), nil, tool.NewRegistry(), nil)
defer r.host.Close()
d := agentcomposition.Builtins()[0]
d.MCP = agentcomposition.Selection{Mode: agentcomposition.Select, Members: []agentcomposition.Module{{ID: "allowed"}}}
r.ConfigureComposition(d)
var current []config.PluginEntry
r.ConfigureInventorySource(func() ([]config.PluginEntry, []plugin.Spec, map[string]bool, error) {
var specs []plugin.Spec
enabled := map[string]bool{}
for _, e := range current {
specs = append(specs, plugin.Spec{Name: e.Name, Type: "http", URL: e.URL})
enabled[e.Name] = true
}
return current, specs, enabled, nil
})
current = []config.PluginEntry{{Name: "allowed", URL: "https://example.invalid"}, {Name: "denied", URL: "https://example.invalid"}}
entries, _, _, _, _ := r.CapabilityCatalogState()
if len(entries) != 1 || entries[0].Name != "allowed" {
t.Fatalf("inventory escaped whitelist: %+v", entries)
}
current = nil
entries, _, _, _, _ = r.CapabilityCatalogState()
if len(entries) != 0 || r.serverEnabled("allowed") {
t.Fatal("removed server remains callable")
}
}
func TestLiveCompositionRechecksAuthorityAfterResolution(t *testing.T) {
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
r := NewMCPCapabilityRuntime(t.Context(), plugin.NewHost(), nil, tool.NewRegistry(), nil)
defer r.host.Close()
r.ConfigureComposition(agentcomposition.Builtins()[0])
spec := plugin.Spec{Name: "changing", Type: "http", URL: server.URL, Authorized: true, ConfigSource: "user_config"}
r.ConfigureInventorySource(func() ([]config.PluginEntry, []plugin.Spec, map[string]bool, error) {
return []config.PluginEntry{{Name: spec.Name}}, []plugin.Spec{spec}, map[string]bool{spec.Name: true}, nil
})
resolved, err := r.NewFrontend(nil, nil).ResolveCall(t.Context(), json.RawMessage(`{"action":"call","capability_id":"mcp-server:changing"}`))
if err != nil || resolved.Target == nil {
t.Fatalf("authorized target was not resolved: %+v %v", resolved, err)
}
spec.Authorized, spec.ConfigSource = false, "project_config"
if _, err := resolved.Target.Execute(t.Context(), resolved.Args); err == nil {
t.Fatal("stale authorization reached connection startup")
}
if len(r.host.ServerNames()) != 0 || requests.Load() != 0 {
t.Fatal("revoked source established a connection")
}
}
@@ -19,6 +19,13 @@ func (r *MCPCapabilityRuntime) UpsertServer(entry config.PluginEntry, raw plugin
return
}
spec := cloneMCPSpec(raw)
if !r.compositionAllows(spec) {
r.mu.Lock()
delete(r.servers, spec.Name)
r.mu.Unlock()
r.state.clearServer(spec.Name)
return
}
name := strings.TrimSpace(spec.Name)
if name == "" {
return
@@ -0,0 +1,65 @@
package usecap
import (
"sync"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/ext/plugin"
)
type InventorySource func() ([]config.PluginEntry, []plugin.Spec, map[string]bool, error)
type inventorySource struct {
mu sync.Mutex
load InventorySource
fingerprint string
specs []plugin.Spec
}
func (r *MCPCapabilityRuntime) ConfigureInventorySource(load InventorySource) {
r.source = &inventorySource{load: load}
}
func (r *MCPCapabilityRuntime) refreshInventory() error {
if r == nil || r.source == nil {
return nil
}
source := r.source
source.mu.Lock()
defer source.mu.Unlock()
entries, specs, enabled, err := source.load()
if err != nil {
return err
}
keys := make([]string, 0, len(specs))
for _, spec := range specs {
keys = append(keys, plugin.SchemaCacheKey(spec)+":"+spec.Package)
}
fingerprint := agentcomposition.Digest(struct {
Entries []config.PluginEntry
Keys []string
Enabled map[string]bool
}{entries, keys, enabled})
if source.fingerprint != fingerprint || !sameInventorySpecs(source.specs, specs) {
r.ConfigureServers(entries, specs, enabled)
source.fingerprint = fingerprint
source.specs = make([]plugin.Spec, len(specs))
for i, spec := range specs {
source.specs[i] = cloneMCPSpec(spec)
}
}
return nil
}
func sameInventorySpecs(previous, current []plugin.Spec) bool {
if len(previous) != len(current) {
return false
}
for i, spec := range current {
if previous[i].Authorized != spec.Authorized || !plugin.MCPRuntimeSpecMatches(previous[i], spec) {
return false
}
}
return true
}
+44
View File
@@ -0,0 +1,44 @@
package usecap
import (
"context"
"encoding/json"
"strings"
"reasonix/internal/contract/tool"
"reasonix/internal/ext/plugin"
)
type runtimeResourceReadTool struct {
tool.Tool
proxy *UseCapabilityTool
}
func (r *MCPCapabilityRuntime) ResourceReadTool() tool.Tool {
return &runtimeResourceReadTool{Tool: plugin.ResourceReadTool(r.host), proxy: r.NewFrontend(nil, nil)}
}
func (t *runtimeResourceReadTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var in struct {
Server string `json:"server"`
}
if err := json.Unmarshal(args, &in); err != nil {
return "", err
}
server := strings.TrimSpace(in.Server)
spec, unlock, err := t.proxy.lockAuthorizedRuntimeServer(ctx, server)
if err != nil {
return "", err
}
defer unlock()
var out string
err = t.proxy.runtime.withServerGate(ctx, server, func() error {
if _, err := t.proxy.ensureServerToolsForSpec(ctx, server, spec); err != nil {
return err
}
var err error
out, err = t.Tool.Execute(ctx, args)
return err
})
return out, err
}
@@ -0,0 +1,71 @@
package usecap
import (
"context"
"encoding/json"
"reasonix/internal/contract/tool"
)
// runtimeBoundMCPTool keeps the provider-visible MCP adapter unchanged while
// binding execution to the current controller runtime. The underlying Host may
// be shared by sibling tabs, so a server name alone must never authorize reuse.
type runtimeBoundMCPTool struct {
proxy *UseCapabilityTool
target tool.Tool
server string
authorized bool
}
func (b *runtimeBoundMCPTool) Name() string { return b.target.Name() }
func (b *runtimeBoundMCPTool) Description() string { return b.target.Description() }
func (b *runtimeBoundMCPTool) Schema() json.RawMessage { return b.target.Schema() }
func (b *runtimeBoundMCPTool) ReadOnly() bool { return b.target.ReadOnly() }
func (b *runtimeBoundMCPTool) MCPServerAuthorized() bool { return b.authorized }
func (b *runtimeBoundMCPTool) MCPServerName() string { return b.server }
func (b *runtimeBoundMCPTool) MCPRawToolName() string { return mcpRawToolName(b.target) }
func (b *runtimeBoundMCPTool) MCPDestructiveHint() bool { return tool.HasMCPDestructiveHint(b.target) }
func (b *runtimeBoundMCPTool) MCPVisibleToolName() string {
if meta, ok := b.target.(tool.MCPVisibleMetadata); ok {
return meta.MCPVisibleToolName()
}
return b.MCPRawToolName()
}
func (b *runtimeBoundMCPTool) MCPPackageName() string {
if meta, ok := b.target.(tool.MCPPackageMetadata); ok {
return meta.MCPPackageName()
}
return ""
}
func (b *runtimeBoundMCPTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var out string
err := b.proxy.withRuntimeBoundMCP(ctx, b.server, b.target, func() error {
var execErr error
out, execErr = b.target.Execute(ctx, args)
return execErr
})
return out, err
}
func (b *runtimeBoundMCPTool) ExecuteWithImages(ctx context.Context, args json.RawMessage) (string, []string, error) {
var out string
var images []string
err := b.proxy.withRuntimeBoundMCP(ctx, b.server, b.target, func() error {
if imageTool, ok := b.target.(tool.ImageTool); ok {
var execErr error
out, images, execErr = imageTool.ExecuteWithImages(ctx, args)
return execErr
}
var execErr error
out, execErr = b.target.Execute(ctx, args)
return execErr
})
return out, images, err
}
func mcpRawToolName(target tool.Tool) string {
if meta, ok := target.(tool.MCPMetadata); ok {
return meta.MCPRawToolName()
}
return ""
}
+36 -67
View File
@@ -10,6 +10,7 @@ import (
"sync"
"time"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/tool"
@@ -34,6 +35,8 @@ type MCPCapabilityRuntime struct {
// target that has not begun its final runtime-bound execution check.
dispatchMu sync.RWMutex
compositionFrozen bool
composition *agentcomposition.Definition
source *inventorySource
mu sync.RWMutex
servers map[string]mcpRuntimeServer
gates mcpServerGates
@@ -90,6 +93,9 @@ func (r *MCPCapabilityRuntime) ConfigureServers(entries []config.PluginEntry, sp
next := make(map[string]mcpRuntimeServer, len(specs))
for _, raw := range specs {
spec := cloneMCPSpec(raw)
if !r.compositionAllows(spec) {
continue
}
name := strings.TrimSpace(spec.Name)
if name == "" {
continue
@@ -112,8 +118,18 @@ func (r *MCPCapabilityRuntime) ConfigureServers(entries []config.PluginEntry, sp
}
}
r.mu.Lock()
previous := r.servers
r.servers = next
r.mu.Unlock()
for name, old := range previous {
current, exists := next[name]
if !exists || !current.enabled || old.spec.Authorized != current.spec.Authorized || !plugin.MCPRuntimeSpecMatches(old.spec, current.spec) {
r.state.clearServer(name)
if r.source != nil && r.host != nil {
r.host.Remove(name)
}
}
}
for name := range next {
if !next[name].enabled {
r.state.clearServer(name)
@@ -180,6 +196,9 @@ func (r *MCPCapabilityRuntime) CapabilityCatalogState() (entries []config.Plugin
if r == nil {
return nil, nil, nil, nil, nil
}
if r.refreshInventory() != nil {
return nil, nil, nil, nil, nil
}
r.dispatchMu.RLock()
defer r.dispatchMu.RUnlock()
entries, cached, keyOK, disabled = r.catalogStateLocked()
@@ -389,70 +408,6 @@ type UseCapabilityTool struct {
state *mcpProxySharedState
}
// runtimeBoundMCPTool keeps the provider-visible MCP adapter unchanged while
// binding execution to the current controller runtime. The underlying Host may
// be shared by sibling tabs, so a server name alone must never authorize reuse.
type runtimeBoundMCPTool struct {
proxy *UseCapabilityTool
target tool.Tool
server string
authorized bool
}
func (b *runtimeBoundMCPTool) Name() string { return b.target.Name() }
func (b *runtimeBoundMCPTool) Description() string { return b.target.Description() }
func (b *runtimeBoundMCPTool) Schema() json.RawMessage { return b.target.Schema() }
func (b *runtimeBoundMCPTool) ReadOnly() bool { return b.target.ReadOnly() }
func (b *runtimeBoundMCPTool) MCPServerAuthorized() bool { return b.authorized }
func (b *runtimeBoundMCPTool) MCPServerName() string { return b.server }
func (b *runtimeBoundMCPTool) MCPRawToolName() string { return mcpRawToolName(b.target) }
func (b *runtimeBoundMCPTool) MCPDestructiveHint() bool { return tool.HasMCPDestructiveHint(b.target) }
func (b *runtimeBoundMCPTool) MCPVisibleToolName() string {
if meta, ok := b.target.(tool.MCPVisibleMetadata); ok {
return meta.MCPVisibleToolName()
}
return b.MCPRawToolName()
}
func (b *runtimeBoundMCPTool) MCPPackageName() string {
if meta, ok := b.target.(tool.MCPPackageMetadata); ok {
return meta.MCPPackageName()
}
return ""
}
func (b *runtimeBoundMCPTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var out string
err := b.proxy.withRuntimeBoundMCP(ctx, b.server, b.target, func() error {
var execErr error
out, execErr = b.target.Execute(ctx, args)
return execErr
})
return out, err
}
func (b *runtimeBoundMCPTool) ExecuteWithImages(ctx context.Context, args json.RawMessage) (string, []string, error) {
var out string
var images []string
err := b.proxy.withRuntimeBoundMCP(ctx, b.server, b.target, func() error {
if imageTool, ok := b.target.(tool.ImageTool); ok {
var execErr error
out, images, execErr = imageTool.ExecuteWithImages(ctx, args)
return execErr
}
var execErr error
out, execErr = b.target.Execute(ctx, args)
return execErr
})
return out, images, err
}
func mcpRawToolName(target tool.Tool) string {
if meta, ok := target.(tool.MCPMetadata); ok {
return meta.MCPRawToolName()
}
return ""
}
// NewUseCapabilityTool builds a standalone capability proxy (tests and simple
// boots). Prefer MCPCapabilityRuntime.NewFrontend when multiple agents share
// one session Host.
@@ -547,6 +502,11 @@ func (t *UseCapabilityTool) ResolveCall(ctx context.Context, args json.RawMessag
CapabilityID: id,
Args: p.Arguments,
}
if t.runtime != nil {
if err := t.runtime.refreshInventory(); err != nil {
return tool.ResolvedCall{}, err
}
}
if resolved, ok := t.resolveDisabled(base, id, ""); ok {
return resolved, nil
}
@@ -867,8 +827,8 @@ func (t *UseCapabilityTool) resolveCall(ctx context.Context, id string, args jso
if lockErr != nil {
return t.resolveUnavailable(base, id, plugin.ModelToolName(server, raw), lockErr.Error()), nil
}
defer unlock()
runtimeSpec = spec
unlock()
}
// Prefer already-exposed registry tool (auto-started MCP). The model name
// MUST come from the plugin layer's canonical constructor: it appends a
@@ -878,7 +838,7 @@ func (t *UseCapabilityTool) resolveCall(ctx context.Context, id string, args jso
modelName := plugin.ModelToolName(server, raw)
if t.registry != nil {
if tl, ok := routableRegistryTool(t.registry, modelName); ok {
if t.runtime != nil && !plugin.MCPToolMatchesSpec(tl, runtimeSpec) {
if t.runtime != nil && !mcpToolMatchesRuntime(tl, runtimeSpec) {
return t.resolveUnavailable(base, id, modelName, fmt.Sprintf("connected MCP server %q identity does not match the current runtime configuration", server)), nil
}
base.TargetName = modelName
@@ -1136,7 +1096,7 @@ func (o *onDemandMCPTool) executeWithImages(ctx context.Context, args json.RawMe
}
return "", nil, fmt.Errorf("%s", msg)
}
if !plugin.MCPToolMatchesSpec(target, spec) {
if !mcpToolMatchesRuntime(target, spec) {
return "", nil, fmt.Errorf("connected MCP server %q identity does not match the current runtime configuration; reconnect this server before retrying", o.server)
}
if _, err := plugin.ReconcileCachedToolSafety(o.server, o.raw, plugin.CachedToolSafety{
@@ -1286,6 +1246,9 @@ func (t *UseCapabilityTool) lockAuthorizedRuntimeServer(ctx context.Context, ser
return spec, func() {}, nil
}
if err := t.runtime.refreshInventory(); err != nil {
return plugin.Spec{}, func() {}, err
}
t.runtime.dispatchMu.RLock()
unlock := t.runtime.dispatchMu.RUnlock
t.runtime.mu.RLock()
@@ -1311,6 +1274,9 @@ func (t *UseCapabilityTool) bindRuntimeMCP(spec plugin.Spec, target tool.Tool) t
if t.runtime == nil || target == nil {
return target
}
if bound, ok := target.(*runtimeBoundMCPTool); ok && bound.proxy.runtime == t.runtime {
return target
}
return &runtimeBoundMCPTool{
proxy: t,
target: target,
@@ -1328,6 +1294,9 @@ func (t *UseCapabilityTool) withRuntimeBoundMCP(ctx context.Context, server stri
return err
}
defer unlock()
if !spec.ToolEnabled(mcpRawToolName(target)) {
return fmt.Errorf("MCP tool %s is disabled", target.Name())
}
if !plugin.MCPToolMatchesSpec(target, spec) {
return fmt.Errorf("connected MCP server %q identity does not match the current runtime configuration; reconnect this server before retrying", server)
}
+1 -1
View File
@@ -79,7 +79,7 @@ func (c *Controller) admitGuardedTurn(body func(ctx context.Context) error, park
releaseWorkspace.release()
}
}()
binding := c.CompositionBinding()
binding := c.CompositionSummary()
c.mu.Lock()
if c.gate.closed {
c.mu.Unlock()
+6 -1
View File
@@ -11,6 +11,11 @@ import (
// Commands returns the loaded custom slash commands.
func (c *Controller) Commands() []command.Command {
if summary := c.CompositionSummary(); summary != nil && summary.ResourceMode == "live" {
b := c.CompositionBinding()
cmds, _ := command.LoadRoots(config.RootsForHome(c.PresetStore().Home).CommandRootsForRoot(c.workspaceRoot)...)
return compositionCommands(cmds, b.Resolved)
}
if p := c.commands.Load(); p != nil {
return *p
}
@@ -20,7 +25,7 @@ func (c *Controller) Commands() []command.Command {
// ReloadCommands rescans all command directories and hot-swaps the slash_command
// tool and the internal command slice — no MCP restart, no hook rerun.
func (c *Controller) ReloadCommands(ctx context.Context) error {
if c.CompositionBinding() != nil {
if c.CompositionSummary() != nil {
return ctx.Err()
}
select {
+16 -6
View File
@@ -63,11 +63,11 @@ func (c *Controller) SetDefaultAgentPreset(id string, workspace bool) error {
}
func (c *Controller) SelectAgentComposition(ctx context.Context, id string) error {
if id == "builtin:general" && c.CompositionBinding() == nil {
if id == "builtin:general" && c.CompositionSummary() == nil {
return nil
}
if current := c.CompositionBinding(); current != nil && current.Version.Definition.ID == id {
if v, err := c.PresetStore().Get(id); err == nil && v.Revision == current.Version.Revision {
if current := c.CompositionSummary(); current != nil && current.ID == id {
if v, err := c.PresetStore().Get(id); err == nil && v.Revision == current.Revision {
return nil
}
}
@@ -95,6 +95,13 @@ func (c *Controller) CompositionBinding() *agentcomposition.Binding {
return c.executor.Session().Composition()
}
func (c *Controller) CompositionSummary() *agentcomposition.Summary {
if c.executor == nil {
return nil
}
return c.executor.Session().CompositionSummary()
}
func (c *Controller) BindComposition(binding *agentcomposition.Binding) {
if c.executor != nil {
c.executor.Session().BindComposition(binding)
@@ -166,6 +173,9 @@ func (c *Controller) compositionEnqueueReady() error {
}
func (c *Controller) persistCompositionAdmission() error {
if summary := c.CompositionSummary(); summary == nil || summary.Started {
return nil
}
old := c.CompositionBinding()
if old == nil || old.Started {
return nil
@@ -185,11 +195,11 @@ func (c *Controller) persistCompositionAdmission() error {
}
func (c *Controller) beginCompositionEnqueue() (func() error, error) {
old := c.CompositionBinding()
noop := func() error { return nil }
if old == nil || old.Started {
if summary := c.CompositionSummary(); summary == nil || summary.Started {
return noop, nil
}
old := c.CompositionBinding()
binding := agentcomposition.Clone(old)
binding.Started = true
if err := c.persistCompositionAdmission(); err != nil {
@@ -209,7 +219,7 @@ func (c *Controller) beginCompositionEnqueue() (func() error, error) {
}
func (c *Controller) compositionNeedsSnapshot(explicit bool) bool {
b := c.CompositionBinding()
b := c.CompositionSummary()
if b != nil {
return explicit || b.Started
}
@@ -0,0 +1,16 @@
package control
import (
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/ext/command"
)
func compositionCommands(commands []command.Command, d agentcomposition.Definition) []command.Command {
out := []command.Command{}
for _, cmd := range commands {
if d.Commands.Allows(cmd.Name) && (cmd.Plugin == "" || d.Extensions.Allows(cmd.Plugin)) {
out = append(out, cmd)
}
}
return out
}
@@ -14,6 +14,19 @@ func (c *Controller) pinnedMCPEntry(live config.PluginEntry) (config.PluginEntry
if binding == nil {
return live, nil
}
if binding.Live() {
if !binding.Resolved.MCP.Allows(live.Name) {
return live, fmt.Errorf("%w: MCP %s is outside this composition", agentcomposition.ErrLocked, live.Name)
}
cfg, err := config.RootsForHome(c.PresetStore().Home).LoadForRootReadOnly(c.WorkspaceRoot())
if err != nil {
return live, err
}
if owner, owned := cfg.PluginPackageOwner(live.Name); owned && !binding.Resolved.Extensions.Allows(owner) {
return live, fmt.Errorf("%w: MCP extension %s is outside this composition", agentcomposition.ErrLocked, owner)
}
return applyLiveMCPParameters(live, binding.Resolved.MCP)
}
for _, r := range binding.Resources {
if r.Kind != "mcp" || r.ID != live.Name {
continue
@@ -45,3 +58,12 @@ func (c *Controller) pinnedMCPEntry(live config.PluginEntry) (config.PluginEntry
}
return live, fmt.Errorf("%w: MCP %s is outside this composition", agentcomposition.ErrLocked, live.Name)
}
func applyLiveMCPParameters(entry config.PluginEntry, selection agentcomposition.Selection) (config.PluginEntry, error) {
for _, m := range selection.Members {
if m.ID == entry.Name {
return agentpreset.ApplyMCPParameters(entry, m.Parameters)
}
}
return entry, nil
}
@@ -23,8 +23,8 @@ func (c *Controller) compositionNotice(fields []string) {
}
return
}
if b := c.CompositionBinding(); b != nil && b.Started {
c.notice(fmt.Sprintf("%s — %s (%s). This conversation is locked; start a new session to choose another composition.", b.Version.Definition.ID, b.Version.Definition.Name, b.Version.Revision[:min(12, len(b.Version.Revision))]))
if b := c.CompositionSummary(); b != nil && b.Started {
c.notice(fmt.Sprintf("%s — %s (%s). This conversation is locked; start a new session to choose another composition.", b.ID, b.Name, b.Revision[:min(12, len(b.Revision))]))
return
}
catalog, err := c.AgentPresets()
@@ -40,8 +40,8 @@ func (c *Controller) compositionNotice(fields []string) {
}
func (c *Controller) compositionArgItems() []SlashItem {
if b := c.CompositionBinding(); b != nil && b.Started {
return []SlashItem{{Label: b.Version.Definition.ID, Insert: b.Version.Definition.ID, Hint: b.Version.Definition.Name}}
if b := c.CompositionSummary(); b != nil && b.Started {
return []SlashItem{{Label: b.ID, Insert: b.ID, Hint: b.Name}}
}
catalog, err := c.PresetStore().List()
if err != nil {
@@ -13,8 +13,16 @@ type compositionHistory interface {
}
func CompositionStarted(c compositionHistory) bool {
if binding := c.CompositionBinding(); binding != nil && binding.Started {
return true
if lightweight, ok := c.(interface {
CompositionSummary() *agentcomposition.Summary
}); ok {
if summary := lightweight.CompositionSummary(); summary != nil && summary.Started {
return true
}
} else {
if binding := c.CompositionBinding(); binding != nil && binding.Started {
return true
}
}
if c.Turn() != 0 {
return true
+4 -1
View File
@@ -40,8 +40,11 @@ func (c *Controller) SaveHooks(scope hook.Scope, settings hook.Settings) error {
return err
}
}
if c.CompositionBinding() == nil {
binding := c.CompositionBinding()
if binding == nil {
c.hooks.Replace(hook.Load(opts))
} else if binding.Live() {
c.hooks.Replace(hook.CompositionHooks(hook.Load(opts), binding.Resolved))
} else {
c.hooks.ReplacePolicyGuards(hook.Load(opts))
}
@@ -57,7 +57,7 @@ func (c *Controller) maybeSessionStart(ctx context.Context) {
// resets the executor to a clean session carrying the same system prompt. It
// ends the old session and starts the new one for lifecycle hooks.
func (c *Controller) NewSession() error {
if c.CompositionBinding() != nil {
if c.CompositionSummary() != nil {
return c.newCompositionSession()
}
if c.executor == nil {
+1 -1
View File
@@ -29,7 +29,7 @@ func (c *Controller) runSynchronousTurn(
}
}()
ctx, cancel := context.WithCancel(extension.ContextWithRuntimeOwner(ctx, c.RuntimeOwner()))
binding := c.CompositionBinding()
binding := c.CompositionSummary()
c.mu.Lock()
// Finishing is part of the gate: TurnDone is still fanning out. Closed
// seals a torn-down controller. Blocking callers get an error rather than
+33
View File
@@ -0,0 +1,33 @@
package agentpreset
import (
"encoding/json"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
)
func ApplyMCPParameters(entry config.PluginEntry, raw json.RawMessage) (config.PluginEntry, error) {
p, err := agentcomposition.ParseParameters(raw)
if err != nil {
return entry, err
}
if p.Args != nil {
entry.Args = *p.Args
}
if p.URL != nil {
entry.URL = *p.URL
}
if p.Env != nil {
entry.Env = p.Env
}
if p.Headers != nil {
entry.Headers = p.Headers
}
if p.CallTimeoutSeconds != nil {
entry.CallTimeoutSeconds = *p.CallTimeoutSeconds
}
if p.StartupTimeoutSeconds != nil {
entry.StartupTimeoutSeconds = *p.StartupTimeoutSeconds
}
return entry, nil
}
@@ -5,6 +5,20 @@ import (
"reasonix/internal/contract/provider"
)
func (s *Session) CompositionSummary() *agentcomposition.Summary {
if s == nil {
return nil
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, m := range s.Messages {
if m.Role == provider.RoleSystem && m.AgentComposition != nil {
return m.AgentComposition.Summary()
}
}
return nil
}
func (s *Session) Composition() *agentcomposition.Binding {
if s == nil {
return nil
@@ -20,6 +20,7 @@ type NativeArchive struct {
const NativeArchiveFormat = "reasonix-native-session"
const NativeArchiveVersion = 2
const NativeArchiveLiveVersion = 3
const MaxNativeArchiveBytes = 192 << 20
var ErrNativeArchiveInvalid = errors.New("invalid native session archive")
@@ -31,6 +32,15 @@ func ExportNativeArchive(path string) ([]byte, error) {
return nil, err
}
archive := NativeArchive{Format: NativeArchiveFormat, Version: NativeArchiveVersion, Records: s.Snapshot()}
if summary := s.CompositionSummary(); summary != nil && summary.FormatVersion != 0 {
if summary.FormatVersion != agentcomposition.LiveBindingVersion {
return nil, ErrNativeArchiveUnsupported
}
if err := s.Composition().Validate(); err != nil {
return nil, fmt.Errorf("%w: %w", ErrNativeArchiveInvalid, err)
}
archive.Version = NativeArchiveLiveVersion
}
data, err := json.Marshal(archive)
if len(data) > MaxNativeArchiveBytes {
return nil, ErrSessionReplayLimitExceeded
@@ -51,7 +61,7 @@ func DecodeNativeArchive(data []byte) (*Session, error) {
if err := decoder.Decode(new(any)); err != io.EOF {
return nil, fmt.Errorf("%w: trailing data", ErrNativeArchiveInvalid)
}
if archive.Format != NativeArchiveFormat || archive.Version != NativeArchiveVersion {
if archive.Format != NativeArchiveFormat || (archive.Version != NativeArchiveVersion && archive.Version != NativeArchiveLiveVersion) {
return nil, fmt.Errorf("%w: %d", ErrNativeArchiveUnsupported, archive.Version)
}
if len(archive.Records) > sessionEventReplayMaxMessages {
@@ -66,6 +76,9 @@ func DecodeNativeArchive(data []byte) (*Session, error) {
return nil, agentcomposition.ErrInvalid
}
binding = m.AgentComposition
if binding.Live() && archive.Version != NativeArchiveLiveVersion {
return nil, ErrNativeArchiveUnsupported
}
if err := binding.Validate(); err != nil {
return nil, err
}
@@ -3,14 +3,18 @@ package sessionstore
import "reasonix/internal/contract/provider"
const sessionEventCompositionSchemaVersion = 4
const sessionEventLiveCompositionSchemaVersion = 5
func compositionRemoved(previousSchema int, msgs []provider.Message) bool {
return previousSchema == sessionEventCompositionSchemaVersion && compositionEventSchema("", msgs, nil) != sessionEventCompositionSchemaVersion
return (previousSchema == sessionEventCompositionSchemaVersion || previousSchema == sessionEventLiveCompositionSchemaVersion) && compositionEventSchema("", msgs, nil) != previousSchema
}
func compositionEventSchema(_ string, msgs []provider.Message, images []sessionEventImages) int {
for _, m := range msgs {
if m.AgentComposition != nil {
if m.AgentComposition.Live() {
return sessionEventLiveCompositionSchemaVersion
}
return sessionEventCompositionSchemaVersion
}
}
@@ -0,0 +1,61 @@
package sessionstore
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
"reasonix/internal/contract/agentcomposition"
)
func TestLiveCompositionStorageAndArchiveBoundary(t *testing.T) {
d := agentcomposition.Builtins()[0]
b := &agentcomposition.Binding{FormatVersion: 2, ResourceMode: "live", Version: agentcomposition.Version{Definition: d, Revision: agentcomposition.Revision(d)}, Resolved: d, Resources: []agentcomposition.Resource{}}
b.Seal()
s := NewSession("fixed prompt")
s.BindComposition(b)
path := filepath.Join(t.TempDir(), "session.jsonl")
if err := s.SaveSnapshot(path); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(SessionEventLogPath(path))
if err != nil || !bytes.Contains(raw, []byte(`"schema_version":5`)) {
t.Fatalf("missing downgrade fence: %s %v", raw, err)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatal(err)
}
if summary := loaded.CompositionSummary(); summary == nil || summary.ResourceMode != "live" || summary.Digest != b.Digest {
t.Fatalf("binding summary changed: %+v", summary)
}
archive, err := ExportNativeArchive(path)
if err != nil {
t.Fatal(err)
}
var envelope NativeArchive
if err := json.Unmarshal(archive, &envelope); err != nil || envelope.Version != 3 {
t.Fatalf("archive version: %d %v", envelope.Version, err)
}
imported, err := DecodeNativeArchive(archive)
if err != nil || imported.CompositionSummary().Digest != b.Digest {
t.Fatalf("archive round trip: %v", err)
}
envelope.Version = 2
downgraded, _ := json.Marshal(envelope)
if _, err := DecodeNativeArchive(downgraded); err == nil {
t.Fatal("live binding accepted inside a legacy archive")
}
}
func TestLiveBindingRejectsInlineResources(t *testing.T) {
d := agentcomposition.Builtins()[0]
b := &agentcomposition.Binding{FormatVersion: 2, ResourceMode: "live", Version: agentcomposition.Version{Definition: d, Revision: agentcomposition.Revision(d)}, Resolved: d,
Resources: []agentcomposition.Resource{{Kind: "skill", ID: "x", Data: json.RawMessage(`{}`)}}}
b.Seal()
if b.Validate() == nil {
t.Fatal("live binding retained resource bytes")
}
}
@@ -207,7 +207,7 @@ func replaySessionEventLog(path string) (sessionEventReplay, error) {
// nativeSessionEventSchema reports whether this build writes and replays records
// of schema v; 2 is 1.x's DAG log, read by its own replay.
func nativeSessionEventSchema(v int) bool {
return v == sessionEventSchemaVersion || v == sessionEventImageBlobsSchemaVersion || v == sessionEventCompositionSchemaVersion
return v == sessionEventSchemaVersion || v == sessionEventImageBlobsSchemaVersion || v == sessionEventCompositionSchemaVersion || v == sessionEventLiveCompositionSchemaVersion
}
// replaySessionEventLogWithLimits replays the log, then restores any image
@@ -0,0 +1,4 @@
- 修复:新建自定义预设会话按需加载技能和扩展、连接 MCP,不再生成全量快照。继承模式跟随当前已授权配置。Refs #13988.
- Fixed: new custom presets load skills/extensions and connect MCP on demand. Inherited permissions follow current authorized configuration. Refs #13988.
- 兼容性:已有快照会话保持原行为。新会话使用事件 schema 5、归档 version 3,旧客户端不能继续执行;升级后仍可读取旧会话。Refs #13988.
- Compatibility: existing snapshots remain supported. Live sessions use event schema 5 and archive version 3; older clients cannot execute them. Refs #13988.