From 073079d675506700efc79304cd7964929dfc460e Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:49:01 +0800 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 4 +- .../frontend-next/src/i18n/en_composition.ts | 3 + desktop/frontend-next/src/port/composition.ts | 2 +- .../src/port/mock_composition.ts | 4 +- ...gentCompositionPicker.interaction.test.tsx | 11 + .../src/ui/AgentCompositionPicker.tsx | 2 +- .../src/ui/CompositionCatalog.tsx | 2 +- .../src/ui/CompositionEditor.tsx | 2 +- docs/AGENT_COMPOSITIONS.md | 80 ++++- docs/AGENT_COMPOSITIONS.zh.md | 54 ++- internal/assembly/boot/build.go | 159 +++------ internal/assembly/boot/build_adapters.go | 22 ++ internal/assembly/boot/composition.go | 32 +- .../assembly/boot/composition_extensions.go | 3 + internal/assembly/boot/composition_hooks.go | 3 + .../boot/composition_linked_skill_test.go | 6 +- internal/assembly/boot/composition_live.go | 100 ++++++ .../boot/composition_live_extension_test.go | 127 +++++++ .../assembly/boot/composition_live_hooks.go | 12 + .../boot/composition_live_inventory.go | 32 ++ .../assembly/boot/composition_live_mcp.go | 24 ++ .../boot/composition_live_resource_test.go | 57 +++ .../assembly/boot/composition_live_skills.go | 22 ++ .../assembly/boot/composition_live_test.go | 167 +++++++++ internal/assembly/boot/composition_mcp.go | 24 +- .../assembly/boot/composition_mcp_contract.go | 2 +- .../boot/composition_mcp_follow_test.go | 4 +- .../assembly/boot/composition_mcp_packages.go | 2 +- .../assembly/boot/composition_mcp_test.go | 19 +- ...composition_project_mcp_parameters_test.go | 5 + .../boot/composition_project_security_test.go | 24 +- .../composition_resource_integration_test.go | 16 +- .../boot/composition_retention_test.go | 6 +- .../boot/composition_snapshot_fixture_test.go | 15 + .../assembly/boot/extension_discovery_tool.go | 43 +++ internal/assembly/boot/extension_tools.go | 38 +- internal/assembly/boot/mcp_resource_tool.go | 6 +- internal/assembly/boot/mcp_specs.go | 15 +- internal/assembly/boot/mcp_wiring.go | 65 ++++ internal/assembly/boot/runtime.go | 13 +- internal/assembly/boot/tool_wiring.go | 51 ++- .../contract/agentcomposition/composition.go | 5 + internal/contract/agentcomposition/live.go | 48 +++ .../contract/agentcomposition/live_test.go | 35 ++ internal/contract/tool/registry_restrict.go | 13 + internal/contract/tool/tool.go | 6 +- internal/ext/command/slashtool.go | 14 +- internal/ext/extension/sidecar/lazy.go | 330 ++++++++++++++++++ internal/ext/extension/sidecar/lazy_test.go | 26 ++ .../ext/extension/sidecar/live_validation.go | 48 +++ internal/ext/extension/sidecar/manager.go | 17 + internal/ext/hook/composition.go | 43 +++ internal/ext/hook/composition_test.go | 23 ++ internal/ext/hook/runner.go | 19 +- internal/ext/skill/confined_supplements.go | 143 ++++++++ .../ext/skill/confined_supplements_test.go | 42 +++ .../confined_supplements_windows_test.go | 49 +++ internal/ext/skill/discover.go | 28 +- internal/ext/skill/invocation_flags.go | 6 +- internal/ext/skill/skill.go | 111 +----- internal/ext/skill/supplements.go | 112 ++++++ internal/frontend/acp/composition.go | 14 +- internal/frontend/serve/agent_presets.go | 31 +- internal/frontend/serve/composition_new.go | 2 +- .../serve/composition_performance_test.go | 109 ++++++ internal/frontend/serve/composition_switch.go | 2 +- internal/runtime/usecap/composition.go | 46 +++ .../runtime/usecap/composition_live_test.go | 69 ++++ internal/runtime/usecap/composition_update.go | 7 + internal/runtime/usecap/inventory_source.go | 65 ++++ internal/runtime/usecap/resource_read.go | 44 +++ internal/runtime/usecap/runtime_bound_mcp.go | 71 ++++ internal/runtime/usecap/usecapability.go | 103 ++---- internal/session/control/admission_guard.go | 2 +- internal/session/control/command_catalog.go | 7 +- internal/session/control/composition.go | 22 +- .../session/control/composition_commands.go | 16 + internal/session/control/composition_mcp.go | 22 ++ internal/session/control/composition_slash.go | 8 +- .../session/control/composition_started.go | 12 +- internal/session/control/hooks.go | 5 +- internal/session/control/session_lifecycle.go | 2 +- internal/session/control/synchronous_turn.go | 2 +- internal/state/agentpreset/parameters.go | 33 ++ internal/state/sessionstore/composition.go | 14 + .../state/sessionstore/composition_archive.go | 15 +- .../state/sessionstore/composition_events.go | 6 +- .../sessionstore/composition_live_test.go | 61 ++++ internal/state/sessionstore/session_events.go | 2 +- .../unreleased/agent-presets-on-demand.md | 4 + 90 files changed, 2759 insertions(+), 433 deletions(-) create mode 100644 internal/assembly/boot/build_adapters.go create mode 100644 internal/assembly/boot/composition_live.go create mode 100644 internal/assembly/boot/composition_live_extension_test.go create mode 100644 internal/assembly/boot/composition_live_hooks.go create mode 100644 internal/assembly/boot/composition_live_inventory.go create mode 100644 internal/assembly/boot/composition_live_mcp.go create mode 100644 internal/assembly/boot/composition_live_resource_test.go create mode 100644 internal/assembly/boot/composition_live_skills.go create mode 100644 internal/assembly/boot/composition_live_test.go create mode 100644 internal/assembly/boot/composition_snapshot_fixture_test.go create mode 100644 internal/assembly/boot/extension_discovery_tool.go create mode 100644 internal/assembly/boot/mcp_wiring.go create mode 100644 internal/contract/agentcomposition/live.go create mode 100644 internal/contract/agentcomposition/live_test.go create mode 100644 internal/ext/extension/sidecar/lazy.go create mode 100644 internal/ext/extension/sidecar/lazy_test.go create mode 100644 internal/ext/extension/sidecar/live_validation.go create mode 100644 internal/ext/hook/composition.go create mode 100644 internal/ext/hook/composition_test.go create mode 100644 internal/ext/skill/confined_supplements.go create mode 100644 internal/ext/skill/confined_supplements_test.go create mode 100644 internal/ext/skill/confined_supplements_windows_test.go create mode 100644 internal/ext/skill/supplements.go create mode 100644 internal/frontend/serve/composition_performance_test.go create mode 100644 internal/runtime/usecap/composition_live_test.go create mode 100644 internal/runtime/usecap/inventory_source.go create mode 100644 internal/runtime/usecap/resource_read.go create mode 100644 internal/runtime/usecap/runtime_bound_mcp.go create mode 100644 internal/session/control/composition_commands.go create mode 100644 internal/state/agentpreset/parameters.go create mode 100644 internal/state/sessionstore/composition_live_test.go create mode 100644 release-notes/studio/unreleased/agent-presets-on-demand.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d90efeafe4..16202710ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 | diff --git a/desktop/frontend-next/src/i18n/en_composition.ts b/desktop/frontend-next/src/i18n/en_composition.ts index debb9ef254..911f2ee94d 100644 --- a/desktop/frontend-next/src/i18n/en_composition.ts +++ b/desktop/frontend-next/src/i18n/en_composition.ts @@ -110,6 +110,9 @@ export const EN_COMPOSITION: Record = { "注入运行环境与离线提示": "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", diff --git a/desktop/frontend-next/src/port/composition.ts b/desktop/frontend-next/src/port/composition.ts index 999c0a18c1..5033a65453 100644 --- a/desktop/frontend-next/src/port/composition.ts +++ b/desktop/frontend-next/src/port/composition.ts @@ -27,6 +27,6 @@ export interface AgentPresetCatalog { modules?: Partial>; } 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[]; diff --git a/desktop/frontend-next/src/port/mock_composition.ts b/desktop/frontend-next/src/port/mock_composition.ts index de035ccc7f..0b13b6bb3e 100644 --- a/desktop/frontend-next/src/port/mock_composition.ts +++ b/desktop/frontend-next/src/port/mock_composition.ts @@ -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."}}); } diff --git a/desktop/frontend-next/src/ui/AgentCompositionPicker.interaction.test.tsx b/desktop/frontend-next/src/ui/AgentCompositionPicker.interaction.test.tsx index 1b365ed012..ca88c4ed12 100644 --- a/desktop/frontend-next/src/ui/AgentCompositionPicker.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/AgentCompositionPicker.interaction.test.tsx @@ -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({}} 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); diff --git a/desktop/frontend-next/src/ui/AgentCompositionPicker.tsx b/desktop/frontend-next/src/ui/AgentCompositionPicker.tsx index 16222001c7..e32d0aea8a 100644 --- a/desktop/frontend-next/src/ui/AgentCompositionPicker.tsx +++ b/desktop/frontend-next/src/ui/AgentCompositionPicker.tsx @@ -76,7 +76,7 @@ export function AgentCompositionPicker({ port, binding, locked: statusLocked, ru pending={busy} label={<>{label}} menuTitle={<>{t("Agent 预设")}{t(locked ? "已锁定" : "首次提交前可切换")}} - head={locked &&

{t("该会话已开始,模式已锁定。请用其他模式新建会话。")}

} + head={<>{locked &&

{t("该会话已开始,模式已锁定。请用其他模式新建会话。")}

}{current && current.resourceMode !== "live" &&

{t("已固定资源快照")}

}} items={items} current={locked ? undefined : current?.id ?? "builtin:general"} onPick={choose} diff --git a/desktop/frontend-next/src/ui/CompositionCatalog.tsx b/desktop/frontend-next/src/ui/CompositionCatalog.tsx index 9b61dce37e..f43bbc50ea 100644 --- a/desktop/frontend-next/src/ui/CompositionCatalog.tsx +++ b/desktop/frontend-next/src/ui/CompositionCatalog.tsx @@ -19,7 +19,7 @@ export function CompositionCatalog({ catalog, onSelect, onCreate, onCopy }: {

{t("Agent 预设")}

-

{t("为不同会话组合人设、工具、技能与扩展。模型保持独立;已开始的会话保留原版本。")}

+

{t("预设决定允许使用哪些能力,实际使用时按需加载当前资源。已开始会话的人设和权限规则保持不变。")}

{t("点击卡片,设置本机新任务的默认模式;已有会话保持当前配置。")}

diff --git a/desktop/frontend-next/src/ui/CompositionEditor.tsx b/desktop/frontend-next/src/ui/CompositionEditor.tsx index 70569a53a6..eb6461abf0 100644 --- a/desktop/frontend-next/src/ui/CompositionEditor.tsx +++ b/desktop/frontend-next/src/ui/CompositionEditor.tsx @@ -191,7 +191,7 @@ function ModuleEditor({ onChange({ mode: e.target.value as "inherit" | "select", members: [] }); }} > - + {value.mode === "select" && ( diff --git a/docs/AGENT_COMPOSITIONS.md b/docs/AGENT_COMPOSITIONS.md index fd35eb2234..2bd5764c24 100644 --- a/docs/AGENT_COMPOSITIONS.md +++ b/docs/AGENT_COMPOSITIONS.md @@ -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 diff --git a/docs/AGENT_COMPOSITIONS.zh.md b/docs/AGENT_COMPOSITIONS.zh.md index 854b207db4..80101f34b5 100644 --- a/docs/AGENT_COMPOSITIONS.zh.md +++ b/docs/AGENT_COMPOSITIONS.zh.md @@ -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 切换组合会保留本会话已校验的额外工作目录。派发新会话时继承精确绑定, 不重新选择当前默认组合;限定工作区的绑定不能派发到另一工作区,会在打开 diff --git a/internal/assembly/boot/build.go b/internal/assembly/boot/build.go index b440da49c4..cc585892f5 100644 --- a/internal/assembly/boot/build.go +++ b/internal/assembly/boot/build.go @@ -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 -} diff --git a/internal/assembly/boot/build_adapters.go b/internal/assembly/boot/build_adapters.go new file mode 100644 index 0000000000..7ebcb961f3 --- /dev/null +++ b/internal/assembly/boot/build_adapters.go @@ -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 +} diff --git a/internal/assembly/boot/composition.go b/internal/assembly/boot/composition.go index e336351137..14ff6f87b6 100644 --- a/internal/assembly/boot/composition.go +++ b/internal/assembly/boot/composition.go @@ -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 diff --git a/internal/assembly/boot/composition_extensions.go b/internal/assembly/boot/composition_extensions.go index 0fea85331d..a08888f4de 100644 --- a/internal/assembly/boot/composition_extensions.go +++ b/internal/assembly/boot/composition_extensions.go @@ -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, "; ")) diff --git a/internal/assembly/boot/composition_hooks.go b/internal/assembly/boot/composition_hooks.go index cdf32930ad..b36448a266 100644 --- a/internal/assembly/boot/composition_hooks.go +++ b/internal/assembly/boot/composition_hooks.go @@ -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 diff --git a/internal/assembly/boot/composition_linked_skill_test.go b/internal/assembly/boot/composition_linked_skill_test.go index 96a8d55585..df9a357fcb 100644 --- a/internal/assembly/boot/composition_linked_skill_test.go +++ b/internal/assembly/boot/composition_linked_skill_test.go @@ -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() } diff --git a/internal/assembly/boot/composition_live.go b/internal/assembly/boot/composition_live.go new file mode 100644 index 0000000000..bf83636ec8 --- /dev/null +++ b/internal/assembly/boot/composition_live.go @@ -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 +} diff --git a/internal/assembly/boot/composition_live_extension_test.go b/internal/assembly/boot/composition_live_extension_test.go new file mode 100644 index 0000000000..c1b1043704 --- /dev/null +++ b/internal/assembly/boot/composition_live_extension_test.go @@ -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") + } +} diff --git a/internal/assembly/boot/composition_live_hooks.go b/internal/assembly/boot/composition_live_hooks.go new file mode 100644 index 0000000000..b3e2913b45 --- /dev/null +++ b/internal/assembly/boot/composition_live_hooks.go @@ -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 +} diff --git a/internal/assembly/boot/composition_live_inventory.go b/internal/assembly/boot/composition_live_inventory.go new file mode 100644 index 0000000000..1a8e89b7ec --- /dev/null +++ b/internal/assembly/boot/composition_live_inventory.go @@ -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 + } +} diff --git a/internal/assembly/boot/composition_live_mcp.go b/internal/assembly/boot/composition_live_mcp.go new file mode 100644 index 0000000000..c86b5fd4ca --- /dev/null +++ b/internal/assembly/boot/composition_live_mcp.go @@ -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 +} diff --git a/internal/assembly/boot/composition_live_resource_test.go b/internal/assembly/boot/composition_live_resource_test.go new file mode 100644 index 0000000000..a25514ec4a --- /dev/null +++ b/internal/assembly/boot/composition_live_resource_test.go @@ -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") + } +} diff --git a/internal/assembly/boot/composition_live_skills.go b/internal/assembly/boot/composition_live_skills.go new file mode 100644 index 0000000000..d3806d9459 --- /dev/null +++ b/internal/assembly/boot/composition_live_skills.go @@ -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, + }) + } +} diff --git a/internal/assembly/boot/composition_live_test.go b/internal/assembly/boot/composition_live_test.go new file mode 100644 index 0000000000..7f2cca7535 --- /dev/null +++ b/internal/assembly/boot/composition_live_test.go @@ -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) + } +} diff --git a/internal/assembly/boot/composition_mcp.go b/internal/assembly/boot/composition_mcp.go index c3663efde5..97694f9dca 100644 --- a/internal/assembly/boot/composition_mcp.go +++ b/internal/assembly/boot/composition_mcp.go @@ -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 { diff --git a/internal/assembly/boot/composition_mcp_contract.go b/internal/assembly/boot/composition_mcp_contract.go index c5c3c843de..65558e9394 100644 --- a/internal/assembly/boot/composition_mcp_contract.go +++ b/internal/assembly/boot/composition_mcp_contract.go @@ -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 diff --git a/internal/assembly/boot/composition_mcp_follow_test.go b/internal/assembly/boot/composition_mcp_follow_test.go index fd6e7441b5..d0543bd712 100644 --- a/internal/assembly/boot/composition_mcp_follow_test.go +++ b/internal/assembly/boot/composition_mcp_follow_test.go @@ -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) } diff --git a/internal/assembly/boot/composition_mcp_packages.go b/internal/assembly/boot/composition_mcp_packages.go index a7548ea2cc..e77a1dff25 100644 --- a/internal/assembly/boot/composition_mcp_packages.go +++ b/internal/assembly/boot/composition_mcp_packages.go @@ -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") diff --git a/internal/assembly/boot/composition_mcp_test.go b/internal/assembly/boot/composition_mcp_test.go index 86b22848a7..fbe352e10b 100644 --- a/internal/assembly/boot/composition_mcp_test.go +++ b/internal/assembly/boot/composition_mcp_test.go @@ -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) } diff --git a/internal/assembly/boot/composition_project_mcp_parameters_test.go b/internal/assembly/boot/composition_project_mcp_parameters_test.go index 7f69eeb357..431c332c09 100644 --- a/internal/assembly/boot/composition_project_mcp_parameters_test.go +++ b/internal/assembly/boot/composition_project_mcp_parameters_test.go @@ -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) diff --git a/internal/assembly/boot/composition_project_security_test.go b/internal/assembly/boot/composition_project_security_test.go index 27e5c44af4..9ffd472071 100644 --- a/internal/assembly/boot/composition_project_security_test.go +++ b/internal/assembly/boot/composition_project_security_test.go @@ -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) diff --git a/internal/assembly/boot/composition_resource_integration_test.go b/internal/assembly/boot/composition_resource_integration_test.go index 3009da5471..3c013c964b 100644 --- a/internal/assembly/boot/composition_resource_integration_test.go +++ b/internal/assembly/boot/composition_resource_integration_test.go @@ -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() } diff --git a/internal/assembly/boot/composition_retention_test.go b/internal/assembly/boot/composition_retention_test.go index 9760f5e84a..5531bbecf9 100644 --- a/internal/assembly/boot/composition_retention_test.go +++ b/internal/assembly/boot/composition_retention_test.go @@ -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 { diff --git a/internal/assembly/boot/composition_snapshot_fixture_test.go b/internal/assembly/boot/composition_snapshot_fixture_test.go new file mode 100644 index 0000000000..2cb4cd4fcd --- /dev/null +++ b/internal/assembly/boot/composition_snapshot_fixture_test.go @@ -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) +} diff --git a/internal/assembly/boot/extension_discovery_tool.go b/internal/assembly/boot/extension_discovery_tool.go new file mode 100644 index 0000000000..8f68011977 --- /dev/null +++ b/internal/assembly/boot/extension_discovery_tool.go @@ -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 +} diff --git a/internal/assembly/boot/extension_tools.go b/internal/assembly/boot/extension_tools.go index 3db48bd30c..bf5b664455 100644 --- a/internal/assembly/boot/extension_tools.go +++ b/internal/assembly/boot/extension_tools.go @@ -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 diff --git a/internal/assembly/boot/mcp_resource_tool.go b/internal/assembly/boot/mcp_resource_tool.go index 98ca3a1ecc..be3a78bd91 100644 --- a/internal/assembly/boot/mcp_resource_tool.go +++ b/internal/assembly/boot/mcp_resource_tool.go @@ -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)) diff --git a/internal/assembly/boot/mcp_specs.go b/internal/assembly/boot/mcp_specs.go index d51bd3d62c..49a25bbf7e 100644 --- a/internal/assembly/boot/mcp_specs.go +++ b/internal/assembly/boot/mcp_specs.go @@ -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} } diff --git a/internal/assembly/boot/mcp_wiring.go b/internal/assembly/boot/mcp_wiring.go new file mode 100644 index 0000000000..ca3961fd8e --- /dev/null +++ b/internal/assembly/boot/mcp_wiring.go @@ -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 +} diff --git a/internal/assembly/boot/runtime.go b/internal/assembly/boot/runtime.go index 2b9db620be..3a34aeacaa 100644 --- a/internal/assembly/boot/runtime.go +++ b/internal/assembly/boot/runtime.go @@ -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() diff --git a/internal/assembly/boot/tool_wiring.go b/internal/assembly/boot/tool_wiring.go index 6fff824839..91002d5423 100644 --- a/internal/assembly/boot/tool_wiring.go +++ b/internal/assembly/boot/tool_wiring.go @@ -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 { diff --git a/internal/contract/agentcomposition/composition.go b/internal/contract/agentcomposition/composition.go index 7f075e1ffd..14981c9c9f 100644 --- a/internal/contract/agentcomposition/composition.go +++ b/internal/contract/agentcomposition/composition.go @@ -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 } diff --git a/internal/contract/agentcomposition/live.go b/internal/contract/agentcomposition/live.go new file mode 100644 index 0000000000..b454e032a4 --- /dev/null +++ b/internal/contract/agentcomposition/live.go @@ -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, + } +} diff --git a/internal/contract/agentcomposition/live_test.go b/internal/contract/agentcomposition/live_test.go new file mode 100644 index 0000000000..eb7cbb8296 --- /dev/null +++ b/internal/contract/agentcomposition/live_test.go @@ -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) + } + }) + } +} diff --git a/internal/contract/tool/registry_restrict.go b/internal/contract/tool/registry_restrict.go index 0bc388efff..8d5c9fe7d6 100644 --- a/internal/contract/tool/registry_restrict.go +++ b/internal/contract/tool/registry_restrict.go @@ -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 diff --git a/internal/contract/tool/tool.go b/internal/contract/tool/tool.go index fcf9b7930e..f8c56c83ce 100644 --- a/internal/contract/tool/tool.go +++ b/internal/contract/tool/tool.go @@ -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() diff --git a/internal/ext/command/slashtool.go b/internal/ext/command/slashtool.go index 7435195d9d..bb03259c13 100644 --- a/internal/ext/command/slashtool.go +++ b/internal/ext/command/slashtool.go @@ -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"` diff --git a/internal/ext/extension/sidecar/lazy.go b/internal/ext/extension/sidecar/lazy.go new file mode 100644 index 0000000000..42ee9e175e --- /dev/null +++ b/internal/ext/extension/sidecar/lazy.go @@ -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) +} diff --git a/internal/ext/extension/sidecar/lazy_test.go b/internal/ext/extension/sidecar/lazy_test.go new file mode 100644 index 0000000000..e912d83356 --- /dev/null +++ b/internal/ext/extension/sidecar/lazy_test.go @@ -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") + } +} diff --git a/internal/ext/extension/sidecar/live_validation.go b/internal/ext/extension/sidecar/live_validation.go new file mode 100644 index 0000000000..6b5f1d58f3 --- /dev/null +++ b/internal/ext/extension/sidecar/live_validation.go @@ -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 +} diff --git a/internal/ext/extension/sidecar/manager.go b/internal/ext/extension/sidecar/manager.go index 33082d8d57..524c1d58e4 100644 --- a/internal/ext/extension/sidecar/manager.go +++ b/internal/ext/extension/sidecar/manager.go @@ -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) diff --git a/internal/ext/hook/composition.go b/internal/ext/hook/composition.go new file mode 100644 index 0000000000..bf9236d796 --- /dev/null +++ b/internal/ext/hook/composition.go @@ -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 +} diff --git a/internal/ext/hook/composition_test.go b/internal/ext/hook/composition_test.go new file mode 100644 index 0000000000..8bf15c8187 --- /dev/null +++ b/internal/ext/hook/composition_test.go @@ -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) + } +} diff --git a/internal/ext/hook/runner.go b/internal/ext/hook/runner.go index 1e254cbd4f..269d0a1a5a 100644 --- a/internal/ext/hook/runner.go +++ b/internal/ext/hook/runner.go @@ -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. diff --git a/internal/ext/skill/confined_supplements.go b/internal/ext/skill/confined_supplements.go new file mode 100644 index 0000000000..9412b26ac9 --- /dev/null +++ b/internal/ext/skill/confined_supplements.go @@ -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 +} diff --git a/internal/ext/skill/confined_supplements_test.go b/internal/ext/skill/confined_supplements_test.go new file mode 100644 index 0000000000..98df7036b3 --- /dev/null +++ b/internal/ext/skill/confined_supplements_test.go @@ -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) + } + }) + } +} diff --git a/internal/ext/skill/confined_supplements_windows_test.go b/internal/ext/skill/confined_supplements_windows_test.go new file mode 100644 index 0000000000..a9b469619c --- /dev/null +++ b/internal/ext/skill/confined_supplements_windows_test.go @@ -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) + } + }) + } +} diff --git a/internal/ext/skill/discover.go b/internal/ext/skill/discover.go index d697eb3cce..d35cee8a76 100644 --- a/internal/ext/skill/discover.go +++ b/internal/ext/skill/discover.go @@ -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 +} diff --git a/internal/ext/skill/invocation_flags.go b/internal/ext/skill/invocation_flags.go index 8c4a163a9d..22b14e23e3 100644 --- a/internal/ext/skill/invocation_flags.go +++ b/internal/ext/skill/invocation_flags.go @@ -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 } diff --git a/internal/ext/skill/skill.go b/internal/ext/skill/skill.go index 4738e1d0d8..3aa998ce7d 100644 --- a/internal/ext/skill/skill.go +++ b/internal/ext/skill/skill.go @@ -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 :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 { diff --git a/internal/ext/skill/supplements.go b/internal/ext/skill/supplements.go new file mode 100644 index 0000000000..92abc298a4 --- /dev/null +++ b/internal/ext/skill/supplements.go @@ -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 + } +} diff --git a/internal/frontend/acp/composition.go b/internal/frontend/acp/composition.go index ada7a75c03..098513c7e5 100644 --- a/internal/frontend/acp/composition.go +++ b/internal/frontend/acp/composition.go @@ -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{} diff --git a/internal/frontend/serve/agent_presets.go b/internal/frontend/serve/agent_presets.go index 5eb7ff824c..8d68079ac6 100644 --- a/internal/frontend/serve/agent_presets.go +++ b/internal/frontend/serve/agent_presets.go @@ -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) { diff --git a/internal/frontend/serve/composition_new.go b/internal/frontend/serve/composition_new.go index 2fc0b5f8ea..63a6ee0dce 100644 --- a/internal/frontend/serve/composition_new.go +++ b/internal/frontend/serve/composition_new.go @@ -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 } diff --git a/internal/frontend/serve/composition_performance_test.go b/internal/frontend/serve/composition_performance_test.go new file mode 100644 index 0000000000..1e7fc4642a --- /dev/null +++ b/internal/frontend/serve/composition_performance_test.go @@ -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() + } + }) + } +} diff --git a/internal/frontend/serve/composition_switch.go b/internal/frontend/serve/composition_switch.go index 858fec298e..3c1bce1dd6 100644 --- a/internal/frontend/serve/composition_switch.go +++ b/internal/frontend/serve/composition_switch.go @@ -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)) diff --git a/internal/runtime/usecap/composition.go b/internal/runtime/usecap/composition.go index b234ea2cd2..142e92c55e 100644 --- a/internal/runtime/usecap/composition.go +++ b/internal/runtime/usecap/composition.go @@ -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 { diff --git a/internal/runtime/usecap/composition_live_test.go b/internal/runtime/usecap/composition_live_test.go new file mode 100644 index 0000000000..9968c18b5d --- /dev/null +++ b/internal/runtime/usecap/composition_live_test.go @@ -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") + } +} diff --git a/internal/runtime/usecap/composition_update.go b/internal/runtime/usecap/composition_update.go index c58962eb8b..4af481b8c0 100644 --- a/internal/runtime/usecap/composition_update.go +++ b/internal/runtime/usecap/composition_update.go @@ -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 diff --git a/internal/runtime/usecap/inventory_source.go b/internal/runtime/usecap/inventory_source.go new file mode 100644 index 0000000000..11f0e2480a --- /dev/null +++ b/internal/runtime/usecap/inventory_source.go @@ -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 +} diff --git a/internal/runtime/usecap/resource_read.go b/internal/runtime/usecap/resource_read.go new file mode 100644 index 0000000000..133b3fc26f --- /dev/null +++ b/internal/runtime/usecap/resource_read.go @@ -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 +} diff --git a/internal/runtime/usecap/runtime_bound_mcp.go b/internal/runtime/usecap/runtime_bound_mcp.go new file mode 100644 index 0000000000..43b882bbd0 --- /dev/null +++ b/internal/runtime/usecap/runtime_bound_mcp.go @@ -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 "" +} diff --git a/internal/runtime/usecap/usecapability.go b/internal/runtime/usecap/usecapability.go index d777f55d57..2e806d99b8 100644 --- a/internal/runtime/usecap/usecapability.go +++ b/internal/runtime/usecap/usecapability.go @@ -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) } diff --git a/internal/session/control/admission_guard.go b/internal/session/control/admission_guard.go index a8414ba76d..5a43af7398 100644 --- a/internal/session/control/admission_guard.go +++ b/internal/session/control/admission_guard.go @@ -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() diff --git a/internal/session/control/command_catalog.go b/internal/session/control/command_catalog.go index faad71f6d3..d4592254c9 100644 --- a/internal/session/control/command_catalog.go +++ b/internal/session/control/command_catalog.go @@ -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 { diff --git a/internal/session/control/composition.go b/internal/session/control/composition.go index 85bd7a08fa..9318f82a46 100644 --- a/internal/session/control/composition.go +++ b/internal/session/control/composition.go @@ -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 } diff --git a/internal/session/control/composition_commands.go b/internal/session/control/composition_commands.go new file mode 100644 index 0000000000..45a4c5686e --- /dev/null +++ b/internal/session/control/composition_commands.go @@ -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 +} diff --git a/internal/session/control/composition_mcp.go b/internal/session/control/composition_mcp.go index 9058dee122..1d8ffcf64e 100644 --- a/internal/session/control/composition_mcp.go +++ b/internal/session/control/composition_mcp.go @@ -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 +} diff --git a/internal/session/control/composition_slash.go b/internal/session/control/composition_slash.go index 88d158d072..bef7628650 100644 --- a/internal/session/control/composition_slash.go +++ b/internal/session/control/composition_slash.go @@ -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 { diff --git a/internal/session/control/composition_started.go b/internal/session/control/composition_started.go index ebb87d6271..a7792b6502 100644 --- a/internal/session/control/composition_started.go +++ b/internal/session/control/composition_started.go @@ -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 diff --git a/internal/session/control/hooks.go b/internal/session/control/hooks.go index 7c088b2777..03671dd415 100644 --- a/internal/session/control/hooks.go +++ b/internal/session/control/hooks.go @@ -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)) } diff --git a/internal/session/control/session_lifecycle.go b/internal/session/control/session_lifecycle.go index 7ed51d228b..9fff961a95 100644 --- a/internal/session/control/session_lifecycle.go +++ b/internal/session/control/session_lifecycle.go @@ -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 { diff --git a/internal/session/control/synchronous_turn.go b/internal/session/control/synchronous_turn.go index 45318e995b..d0c9327ae4 100644 --- a/internal/session/control/synchronous_turn.go +++ b/internal/session/control/synchronous_turn.go @@ -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 diff --git a/internal/state/agentpreset/parameters.go b/internal/state/agentpreset/parameters.go new file mode 100644 index 0000000000..32f6ca909c --- /dev/null +++ b/internal/state/agentpreset/parameters.go @@ -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 +} diff --git a/internal/state/sessionstore/composition.go b/internal/state/sessionstore/composition.go index c0d6b3d18a..0cea1fe86e 100644 --- a/internal/state/sessionstore/composition.go +++ b/internal/state/sessionstore/composition.go @@ -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 diff --git a/internal/state/sessionstore/composition_archive.go b/internal/state/sessionstore/composition_archive.go index 6b75c7747e..d1be0c3636 100644 --- a/internal/state/sessionstore/composition_archive.go +++ b/internal/state/sessionstore/composition_archive.go @@ -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 } diff --git a/internal/state/sessionstore/composition_events.go b/internal/state/sessionstore/composition_events.go index 3a0dced849..2f08b4e998 100644 --- a/internal/state/sessionstore/composition_events.go +++ b/internal/state/sessionstore/composition_events.go @@ -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 } } diff --git a/internal/state/sessionstore/composition_live_test.go b/internal/state/sessionstore/composition_live_test.go new file mode 100644 index 0000000000..67c56d43fd --- /dev/null +++ b/internal/state/sessionstore/composition_live_test.go @@ -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") + } +} diff --git a/internal/state/sessionstore/session_events.go b/internal/state/sessionstore/session_events.go index 6c447a06da..d7f6f5b6fa 100644 --- a/internal/state/sessionstore/session_events.go +++ b/internal/state/sessionstore/session_events.go @@ -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 diff --git a/release-notes/studio/unreleased/agent-presets-on-demand.md b/release-notes/studio/unreleased/agent-presets-on-demand.md new file mode 100644 index 0000000000..1234a0f023 --- /dev/null +++ b/release-notes/studio/unreleased/agent-presets-on-demand.md @@ -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.