mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
049a8c42e1
commit
073079d675
90 files changed
+2759
-433
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user