feat(studio): bind immutable Agent compositions to sessions

Problem: Studio persona settings cannot select an independent tool and module
combination for each conversation, or preserve that combination across reloads.

Root cause: discovery and runtime construction follow mutable shared configuration
without a versioned session composition or a shared admission boundary.

Fix: introduce versioned scoped definitions, retained resources and authoritative
session bindings. Assemble isolated runtime selections, enforce tool ceilings,
pin MCP contracts, and share the lifecycle gate with first-prompt admission.
Expose independent HTTP, CLI, ACP and Studio management and selection controls,
with migration, native import/export, diagnostics and read-only history recovery.

Verification: composition mechanism tests, provider-wire cache tests, lifecycle
rollback tests, sensitive-boundary regressions, frontend typecheck and all 5,219
frontend tests; gofmt, vet, lint and repository checks. Browser light/dark wide
and 390px screenshots and real Electron backend interactions are included.

Refs: #13588
This commit is contained in:
SivanCola committed 2026-10-17 19:09:49 +08:00
1 parent ce29aae765
commit 4fe345ec44
205 files changed
+7313 -736

No files matched your search

+1
View File
@@ -246,6 +246,7 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
[Recovery and updates](./docs/RECOVERY.md) ·
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
- **Agent presets:** [Session Agent compositions](./docs/AGENT_COMPOSITIONS.md)
- **Engineering & migration:** [Spec](./docs/SPEC.md) ·
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
[Tool contract](./docs/TOOL_CONTRACT.md) · [Moving from 1.x to 2.x](./docs/MIGRATING.md) ·
+1
View File
@@ -227,6 +227,7 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.md) ·
[恢复与安全模式](./docs/RECOVERY.md) ·
[Checkpoints 与 rewind](./docs/CHECKPOINTS.md)
- **Agent 预设:** [会话级组合](./docs/AGENT_COMPOSITIONS.zh.md)
- **工程与迁移:** [规格](./docs/SPEC.md) ·
[任务合约与暂停策略](./docs/TASK_CONTRACT.md) ·
[工具合约](./docs/TOOL_CONTRACT.md) ·
+12 -11
View File
@@ -472,17 +472,18 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
// runtimeOptions is what the one pane this host launches with is built from.
func runtimeOptions(root string, sink event.Sink, logs io.Writer, phases *startupPhases, driver *computer.Driver) boot.Options {
return boot.Options{
ComputerDriver: driver,
Version: version,
WorkspaceRoot: root,
SessionDir: serve.SessionDirFor(root),
Sink: sink,
NativeSurfaces: true,
Stderr: logs,
OnPhase: phases.boot,
StatsSource: surface.Desktop,
FeedbackSurface: feedback.SurfaceStudio,
AccountsURL: os.Getenv("REASONIX_ACCOUNTS_URL"),
AgentComposition: "default",
ComputerDriver: driver,
Version: version,
WorkspaceRoot: root,
SessionDir: serve.SessionDirFor(root),
Sink: sink,
NativeSurfaces: true,
Stderr: logs,
OnPhase: phases.boot,
StatsSource: surface.Desktop,
FeedbackSurface: feedback.SurfaceStudio,
AccountsURL: os.Getenv("REASONIX_ACCOUNTS_URL"),
// A stale default_model must not keep the window from opening to fix it.
OpenOnFallbackModel: true,
+27 -2
View File
@@ -48,6 +48,32 @@ export interface UIAction {
}
export const ACTIONS: UIAction[] = [
{id:"agent-composition.select",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
{id:"agent-composition.manage",kind:"view",target:"none",proof:"interaction"},
{id:"agent-composition.new",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
{id:"agent-composition.new-choice",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.reload",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.view",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.create",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.copy",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.scope",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.id",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.name",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.description",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.module-mode",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-id",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-parameters",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-add",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-choose",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-remove",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.save",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
{id:"agent-preset.default",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
{id:"agent-preset.workspace-default",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
{id:"agent-preset.delete-preview",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.delete",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
{id:"agent-preset.delete-cancel",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.export",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.import",kind:"view",target:"none",proof:"interaction"},
// ── The window's own switches, and the way into the turn's policy ────────
// Completion verification is adaptive now, rather than a user-facing preset.
// The legacy endpoint remains in the port for older clients and sessions,
@@ -608,6 +634,7 @@ export const ACTIONS: UIAction[] = [
{ id: "posture-note.dismiss", kind: "view", target: "none", proof: "interaction" },
{ id: "storage.move", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "session.import-legacy", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "session.import-native", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "network.diagnose", kind: "repeatable", target: "none", proof: "interaction" },
// ── Permissions, the sandbox, and the endpoints a session can reach.
@@ -652,10 +679,8 @@ export const ACTIONS: UIAction[] = [
// persona is composed once at boot so the write is read by later sessions.
{ id: "persona.prefix", kind: "view", target: "none", proof: "interaction" },
{ id: "persona.suffix", kind: "view", target: "none", proof: "interaction" },
{ id: "persona.preset", kind: "view", target: "optional", proof: "interaction" },
{ id: "persona.complete", kind: "view", target: "none", proof: "interaction" },
{ id: "persona.runtime-context", kind: "view", target: "none", proof: "interaction" },
{ id: "persona.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// The kernel announces the change on the stream, so no rebuild stands between
// the click and the canonical change.
{ id: "currency.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
+2
View File
@@ -1,4 +1,5 @@
import { EN_SURFACES } from "./en_surfaces";
import { EN_COMPOSITION } from "./en_composition";
// English catalogue, keyed by the Chinese source text. A key that is missing
// here renders as Chinese rather than as a blank, so an incomplete translation
// degrades instead of breaking — and i18n.test.ts is what stops it from
@@ -22,6 +23,7 @@ import { EN_COMMUNITY } from "./en_community";
import { EN_SPONSOR } from "./en_sponsor";
export const EN: Record<string, string> = {
...EN_COMPOSITION,
"此检查点不包含文件快照": "This checkpoint contains no file snapshots",
...EN_SURFACES,
"重命名聊天": "Rename chat",
@@ -0,0 +1,67 @@
export const EN_COMPOSITION: Record<string,string> = {
"导入原生会话": "Import a native session",
"保留组合版本与资源;导入不会安装程序或授予权限。缺少授权的会话仅可查看历史。": "Keep the composition version and resources. Importing installs no programs and grants no permissions. Sessions without authorization open for history only.",
"选择会话 JSON 文件": "Choose a session JSON file",
"会话文件超过大小限制。": "The session file exceeds the size limit.",
"会话已导入,绑定版本保持不变。": "Session imported with its original composition version.",
"通用": "General",
"代码审查": "Code review",
"资料研究": "Research",
"继承当前配置,可读取、执行和修改;操作继续遵守现有权限。": "Inherit current configuration. Read, execute and edit within existing permissions.",
"读取和检索代码,依据证据审查;不运行 shell、不修改文件。": "Read and search code for evidence-based reviews. Shell execution and file edits are disabled.",
"读取代码和文档,搜索与抓取网页;不运行 shell、不修改文件。": "Read code and documents, search and fetch web pages. Shell execution and file edits are disabled.",
"Agent 组合": "Agent composition",
"该会话已开始,组合保持原版本。请用其他组合新建会话。": "This conversation has started and keeps its original composition. Start another conversation to use a different one.",
"首次提交前可切换 Agent 组合": "Choose an Agent composition before the first message",
"原有会话配置": "Legacy session configuration",
"管理 Agent 预设": "Manage Agent presets",
"副本": "Copy",
"Agent 预设": "Agent presets",
"为不同会话组合人设、工具、技能与扩展。模型保持独立;已开始的会话保留原版本。": "Combine a persona, tools, skills and extensions for each conversation. Models stay independent; started conversations keep their original version.",
"读取预设中…": "Loading presets…",
"查看预设": "View preset",
"未保存的预设": "Unsaved preset",
"新建预设": "New preset",
"复制为自定义预设": "Duplicate as custom preset",
"已锁定": "Locked",
"首次提交前可切换": "Switch before the first message",
"当前会话使用旧版": "This conversation uses an older version",
"预设标识": "Preset ID",
"存放范围": "Storage scope",
"个人": "Personal",
"项目共享": "Shared with project",
"内置预设只读,复制后即可编辑。": "Built-in presets are read-only. Duplicate one to edit it.",
"已保存,新会话使用新版": "Saved. New conversations use the updated version.",
"保存预设": "Save preset",
"已设为个人默认预设": "Set as personal default",
"设为个人默认": "Set personal default",
"已设为当前项目默认预设": "Set as this workspace's default",
"设为项目默认": "Set workspace default",
"导出文件": "Export file",
"删除预设": "Delete preset",
"删除后不再供新会话选择,已有会话保留绑定版本。": "Deleting removes it from new conversations. Existing conversations retain their bound version.",
"导入预设文件(先预览,再保存)": "Import a preset file (preview before saving)",
"导入预览已就绪,检查模块后保存。": "Import preview is ready. Check the modules before saving.",
"下载预设 JSON": "Download preset JSON",
"预设名称": "Preset name",
"预设说明": "Preset description",
"仅保留前缀文本,工具仍受组合和现有权限约束。": "Use the prefix alone. Tools remain subject to the composition and existing permissions.",
"注入运行环境与离线提示": "Include runtime context and offline hints",
"选择方式": "Selection mode",
"创建会话时继承当前配置": "Inherit current configuration when creating the conversation",
"仅使用所选项(空列表表示禁用)": "Use selected modules only (empty disables them)",
"独立参数(JSON);env 和 headers 仅填写 ${NAME} 引用": "Per-composition parameters (JSON); use only ${NAME} references in env and headers",
"独立参数": "Parameters",
"参数需要有效的 JSON 对象": "Parameters must be a valid JSON object",
"添加模块": "Add module",
"自动化 Hook": "Automation hooks",
"斜杠命令": "Slash commands",
"用其他组合新建会话": "Start a conversation with another composition",
"组合资源不可用,当前会话仅可查看历史。": "Composition resources are unavailable. This conversation is open for history only.",
"预设内容无效,请检查标识、模块和参数": "Invalid preset. Check its ID, modules and parameters.",
"组合所需资源不可用,无法继续执行": "Required composition resources are unavailable; execution is blocked.",
"会话已开始或正在工作,不能切换组合": "This conversation has started or is busy; its composition cannot be changed.",
"预设已被其他操作修改,草稿已保留,请重新加载后再保存": "The preset changed elsewhere. Your draft is retained; reload before saving.",
"找不到该 Agent 预设": "Agent preset not found",
"内置预设只读,请复制后编辑": "Built-in presets are read-only. Duplicate one to edit it.",
};
+6
View File
@@ -27,6 +27,12 @@ export const SAVED_NOT_APPLIED: readonly string[] = ["provider.saved_while_runni
export const savedNotApplied = (e: unknown): boolean => e instanceof HttpError && SAVED_NOT_APPLIED.includes(e.reason?.code ?? "");
const SAID: Record<string, string> = {
"agent_composition.invalid": "预设内容无效,请检查标识、模块和参数",
"agent_composition.unavailable": "组合所需资源不可用,无法继续执行",
"agent_composition.locked": "会话已开始或正在工作,不能切换组合",
"agent_composition.conflict": "预设已被其他操作修改,草稿已保留,请重新加载后再保存",
"agent_composition.not_found": "找不到该 Agent 预设",
"agent_composition.read_only": "内置预设只读,请复制后编辑",
"surface.value_invalid": "填写内容不符合字段要求,请检查后重试",
"surface.expired": "界面已失效,请等待新的请求",
"surface.schema_invalid": "界面描述不符合约定",
@@ -0,0 +1,29 @@
import definitions from "../../../../internal/contract/agentcomposition/builtins.json";
export type CompositionKind = "tools" | "skills" | "mcp" | "extensions" | "hooks" | "commands";
export interface CompositionModule { id: string; parameters?: Record<string, unknown> }
export interface CompositionSelection { mode: "inherit" | "select"; members: CompositionModule[] }
export interface AgentComposition {
schemaVersion: number;
id: string;
name: string;
description: string;
persona: { prefix: string; suffix: string; complete: boolean; include_runtime_context: boolean };
tools: CompositionSelection;
skills: CompositionSelection;
mcp: CompositionSelection;
extensions: CompositionSelection;
hooks: CompositionSelection;
commands: CompositionSelection;
}
export interface AgentPresetVersion { definition: AgentComposition; revision: string }
export interface AgentPresetCatalog {
presets: AgentPresetVersion[];
default: string;
diagnostics: { code: string; module?: string; detail: string }[];
modules?: Partial<Record<CompositionKind,CompositionModule[]>>;
}
export interface AgentPresetImport {definition:AgentComposition;diagnostics:AgentPresetCatalog["diagnostics"]}
export interface CompositionSummary { id: string; name: string; revision: string; digest: string; started: boolean; unavailable?: string }
export const COMPOSITION_KINDS: CompositionKind[] = ["tools", "skills", "mcp", "extensions", "hooks", "commands"];
export const builtinCompositions = (): AgentComposition[] => structuredClone(definitions) as AgentComposition[];
+11 -5
View File
@@ -19,6 +19,7 @@ function operationHeaders(operationId?: string): Record<string, string> {
// RuntimeView is one open pane. Base is the prefix every request of that pane
// carries, which is also what tells the shell's bus which channel to listen on.
export interface RuntimeView {
agentComposition?: import("./composition").CompositionSummary;
id: string;
base: string;
root: string;
@@ -156,7 +157,7 @@ export interface HistoryHit {
export interface HubPort extends SharePort {
runtimes(): Promise<RuntimeView[]>;
open(req: { root?: string; sessionPath?: string }): Promise<RuntimeView>;
open(req: { root?: string; sessionPath?: string; agentComposition?: string }): Promise<RuntimeView>;
close(id: string): Promise<void>;
tree(window?: TreeWindow): Promise<TreeWorkspace[]>;
/** Conversations, in every workspace, that say these words. */
@@ -185,6 +186,7 @@ export interface HubPort extends SharePort {
autoNameSession(path: string): Promise<{ title: string }>;
exportSession(path: string): Promise<{ name: string; content: string }>;
importLegacySessions(path: string, workspace: string): Promise<LegacyImport>;
importNativeSession(content: unknown, workspace: string): Promise<{ path: string }>;
// The host book with each link's state, or null where this kernel refuses
// remote panes outright — a page served to a browser, rather than the window.
// Null is what lets the sidebar leave the whole section out instead of
@@ -192,7 +194,7 @@ export interface HubPort extends SharePort {
remoteHosts(): Promise<RemoteHost[] | null>;
// Opens a pane on another machine. Slow the first time — the kernel may be
// installing itself over there — so the caller polls remoteHosts for the step.
openRemote(req: { host: string; workspace?: string; sessionPath?: string }): Promise<RuntimeView>;
openRemote(req: { host: string; workspace?: string; sessionPath?: string; agentComposition?:string }): Promise<RuntimeView>;
// The far machine's own workspaces, or null while nothing is open on it:
// there is no kernel over there to ask until a pane puts one there.
remoteTree(host: string): Promise<TreeWorkspace[] | null>;
@@ -305,8 +307,8 @@ export class SseHub implements HubPort {
return (await res.json()) as RuntimeView[];
}
open(req: { root?: string; sessionPath?: string }) {
return this.post<RuntimeView>("/runtimes", { root: req.root ?? "", sessionPath: req.sessionPath ?? "" });
open(req: { root?: string; sessionPath?: string; agentComposition?: string }) {
return this.post<RuntimeView>("/runtimes", { root: req.root ?? "", sessionPath: req.sessionPath ?? "", agentComposition: req.agentComposition });
}
async close(id: string) {
@@ -398,6 +400,10 @@ export class SseHub implements HubPort {
return this.post<LegacyImport>("/tree/sessions/import-legacy", { path, workspace });
}
importNativeSession(content: unknown, workspace: string) {
return this.post<{ path: string }>("/tree/sessions/import-native", { content, workspace });
}
async remoteHosts() {
const res = await fetch("/remotes", { credentials: "same-origin" });
// Not an error to report: this kernel does not do remote panes, or answers
@@ -407,7 +413,7 @@ export class SseHub implements HubPort {
return (await res.json()) as RemoteHost[];
}
openRemote(req: { host: string; workspace?: string; sessionPath?: string }) {
openRemote(req: { host: string; workspace?: string; sessionPath?: string; agentComposition?:string }) {
return this.dial((operationId) =>
this.post<RuntimeView>(
"/remotes/open",
+1
View File
@@ -685,6 +685,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
}
async submit(text: string, chips?: ChipCall): Promise<Queued | void> {
this.compositions.markCompositionStarted();
if (chips && this.queuePaused) return this.queueFollowup(text);
if (this.state.running) {
this.emit({ kind: "steer", text });
+22 -13
View File
@@ -2,6 +2,8 @@ import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, Write
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings, PersonaDraft, PersonaSettings } from "./boundary";
import { HttpError } from "./http_error";
import { MockShell } from "./mock_shell";
import { MockCompositions } from "./mock_composition";
import type { AgentComposition } from "./composition";
// The boundary half of the fixture: what the agent is refused outright, and how
// far an approved write reaches. It carries one rule of each kind already, so
@@ -16,6 +18,16 @@ function effectiveBash(s: SandboxSettings): string {
}
export class MockBoundary extends MockShell {
protected compositions = new MockCompositions();
shareAgentPresetsFrom(other: MockBoundary, workspace:string) { this.compositions=other.compositions.fork(workspace); }
agentPresets() { return this.compositions.agentPresets(); }
saveAgentPreset(d: AgentComposition, expected: string) { return this.compositions.saveAgentPreset(d, expected); }
deleteAgentPreset(id: string, revision: string) { return this.compositions.deleteAgentPreset(id, revision); }
setDefaultAgentPreset(id: string, workspace: boolean) { return this.compositions.setDefaultAgentPreset(id, workspace); }
exportAgentPreset(id: string) { return this.compositions.exportAgentPreset(id); }
importAgentPreset(d: AgentComposition) { return this.compositions.importAgentPreset(d); }
agentComposition() { return this.compositions.agentComposition(); }
selectAgentComposition(id: string) { return this.compositions.selectAgentComposition(id); }
async decideWorkspaceTrust(_trust: "trusted" | "declined") {}
// One interrupted barrier and one that a later turn took over, so the card
// and the reason it disappears are both reachable without a crash.
@@ -219,14 +231,21 @@ export class MockBoundary extends MockShell {
};
async persona(): Promise<PersonaSettings> {
return { ...this.personaState, presets: this.personaState.presets.map((p) => ({ ...p })) };
const catalog=await this.agentPresets();
const definition=catalog.presets.find((v)=>v.definition.id===catalog.default)!.definition;
return { ...this.personaState,...definition.persona, presets: this.personaState.presets.map((p) => ({ ...p })) };
}
async savePersonaSettings(s: PersonaDraft): Promise<PersonaSettings> {
// The same rule the kernel enforces, so the fixture cannot show a screen
// accepting something the real save refuses.
if (s.complete && !s.prefix.trim()) throw new Error("complete requires a prefix");
this.personaState = { ...this.personaState, ...s };
const catalog=await this.agentPresets();
const old=catalog.presets.find((v)=>v.definition.id===catalog.default)!;
const copied=old.definition.id.startsWith("builtin:");
const definition={...old.definition,id:copied ? "user:persona" : old.definition.id,persona:{...s}};
await this.saveAgentPreset(definition,copied ? "" : old.revision);
await this.setDefaultAgentPreset(definition.id,false);
return this.persona();
}
@@ -311,17 +330,7 @@ export class MockBoundary extends MockShell {
return { ...this.watch };
}
// The fixture carries the broken file, because a banner nobody can reach is
// a banner nobody designs. Repairing it here clears it, so the whole
// interaction is reachable without a Go build.
private broken: ConfigProblem | null = {
path: "C:\\Users\\you\\AppData\\Roaming\\reasonix\\config.toml",
line: 263,
key: "plugins.command",
excerpt: 'command = "C:\\Scripts\\mcp.exe"',
repair: 'command = "C:\\\\Scripts\\\\mcp.exe"',
recovered: "last-known-good",
};
private broken: ConfigProblem | null = null;
async configProblem(): Promise<ConfigProblem | null> {
return this.broken && { ...this.broken };
@@ -0,0 +1,20 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { MockHub } from "./mock_hub";
import { builtinCompositions } from "./composition";
describe("remote composition ownership", () => {
it("uses each remote host's own catalog and selected composition", async () => {
const hub = new MockHub();
const local = await hub.open({root:"/same"});
const remote = await hub.openRemote({host:"gpu-box",workspace:"/same",agentComposition:"builtin:review"});
const another = await hub.openRemote({host:"builder",workspace:"/same"});
const custom = {...builtinCompositions()[0],id:"user:only-remote",name:"Remote only"};
await hub.portFor(remote).saveAgentPreset(custom,"");
expect(remote.agentComposition?.id).toBe("builtin:review");
expect((await hub.portFor(local).agentPresets()).presets.some(v=>v.definition.id===custom.id)).toBe(false);
expect((await hub.portFor(another).agentPresets()).presets.some(v=>v.definition.id===custom.id)).toBe(false);
const sibling = await hub.openRemote({host:"gpu-box",workspace:"/same",agentComposition:custom.id});
expect(sibling.agentComposition?.id).toBe(custom.id);
});
});
@@ -0,0 +1,64 @@
import { HttpError } from "./http_error";
import revisions from "../../../../internal/contract/agentcomposition/builtin-revisions.json";
import { builtinCompositions } from "./composition";
import type { AgentComposition, AgentPresetVersion, AgentPresetCatalog, CompositionSummary } from "./composition";
type Library = { revision:number; user:AgentPresetVersion[]; projects:Map<string,AgentPresetVersion[]>; personalDefault:string; workspaceDefaults:Map<string,string> };
export class MockCompositions {
private current: CompositionSummary;
constructor(private readonly library:Library = {revision:0,user:[],projects:new Map(),personalDefault:"builtin:general",workspaceDefaults:new Map()},private readonly workspace = "") {
const catalog=this.catalog;
const v=catalog.presets.find((v)=>v.definition.id===catalog.default) ?? catalog.presets[0];
this.current=this.summary(v);
}
fork(workspace:string) { return new MockCompositions(this.library,workspace); }
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:[]};
}
private summary(v:AgentPresetVersion):CompositionSummary { return {id:v.definition.id,name:v.definition.name,revision:v.revision,digest:`mock-${v.revision}`,started:false}; }
private fail(status:number,code:string):never { throw new HttpError(status,"",{code:`agent_composition.${code}`}); }
async agentPresets() { return structuredClone(this.catalog); }
async saveAgentPreset(definition: AgentComposition, expectedRevision: string): Promise<AgentPresetVersion> {
if (definition.id.startsWith("builtin:")) this.fail(403,"read_only");
const prior=this.catalog.presets.find((v)=>v.definition.id===definition.id);
if ((prior?.revision ?? "")!==expectedRevision) this.fail(409,"conflict");
if (!/^(user|project):[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(definition.id) || !definition.name.trim() || definition.persona.complete && !definition.persona.prefix.trim()) this.fail(400,"invalid");
const version={definition:structuredClone(definition),revision:`mock-${++this.library.revision}`};
const items=definition.id.startsWith("project:") ? this.library.projects.get(this.workspace) ?? [] : this.library.user;
const next=[...items.filter((v)=>v.definition.id!==definition.id),version];
if (definition.id.startsWith("project:")) this.library.projects.set(this.workspace,next); else this.library.user=next;
return structuredClone(version);
}
async deleteAgentPreset(id:string,revision:string) {
if (id.startsWith("builtin:")) this.fail(403,"read_only");
const old=this.catalog.presets.find((v)=>v.definition.id===id);
if (!old || old.revision!==revision) this.fail(409,"conflict");
this.library.user=this.library.user.filter((v)=>v.definition.id!==id);
this.library.projects.set(this.workspace,(this.library.projects.get(this.workspace) ?? []).filter((v)=>v.definition.id!==id));
}
async setDefaultAgentPreset(id:string,workspace:boolean) {
if (!this.catalog.presets.some((v)=>v.definition.id===id)) this.fail(404,"not_found");
if (!workspace && id.startsWith("project:")) this.fail(400,"invalid");
if (workspace) this.library.workspaceDefaults.set(this.workspace,id); else this.library.personalDefault=id;
}
async exportAgentPreset(id:string) {
const v=this.catalog.presets.find((v)=>v.definition.id===id);
if (!v) this.fail(404,"not_found");
return structuredClone(v.definition);
}
async importAgentPreset(content:AgentComposition) {
if (content.schemaVersion!==1 || !content.name || !content.persona) this.fail(400,"invalid");
return {definition:structuredClone(content),diagnostics:[]};
}
async agentComposition() { return structuredClone(this.current); }
async selectAgentComposition(id:string) {
const v=this.catalog.presets.find((v)=>v.definition.id===id);
if (!v) this.fail(404,"not_found");
if (this.current.id===id && this.current.revision===v.revision) return structuredClone(this.current);
if (this.current.started) this.fail(409,"locked");
this.current=this.summary(v);
return structuredClone(this.current);
}
markCompositionStarted() { this.current.started=true; }
}
+19 -2
View File
@@ -11,6 +11,7 @@ export class MockHub implements HubPort {
private readonly ports = new Map<string, AgentPort>();
private readonly machine = { configured: false };
private readonly settings = new MockPort();
private readonly remoteSettings = new Map<string, MockPort>();
private readonly views: RuntimeView[] = [
{ id: "r1", base: "", root: "~/projects/DeepSeek-Reasonix", name: "DeepSeek-Reasonix", sessionPath: "/sessions/mock.jsonl" },
];
@@ -21,7 +22,7 @@ export class MockHub implements HubPort {
return Promise.resolve([...this.views]);
}
open(req: { root?: string; sessionPath?: string }) {
async open(req: { root?: string; sessionPath?: string; agentComposition?:string }) {
const held = req.sessionPath ? this.views.find((v) => v.sessionPath === req.sessionPath) : undefined;
if (held) return Promise.resolve(held);
this.seq++;
@@ -33,6 +34,9 @@ export class MockHub implements HubPort {
name: root.split("/").pop() ?? root,
sessionPath: req.sessionPath,
};
const port=this.portFor(view);
if (req.agentComposition) await port.selectAgentComposition(req.agentComposition);
view.agentComposition=await port.agentComposition() ?? undefined;
this.views.push(view);
return Promise.resolve(view);
}
@@ -73,7 +77,7 @@ export class MockHub implements HubPort {
]);
}
openRemote(req: { host: string; workspace?: string; sessionPath?: string }) {
async openRemote(req: { host: string; workspace?: string; sessionPath?: string; agentComposition?: string }) {
this.seq++;
const workspace = req.workspace || "/srv/training";
const view: RuntimeView = {
@@ -84,6 +88,9 @@ export class MockHub implements HubPort {
host: req.host,
sessionPath: req.sessionPath,
};
const port = this.portFor(view);
if (req.agentComposition) await port.selectAgentComposition(req.agentComposition);
view.agentComposition = await port.agentComposition() ?? undefined;
this.views.push(view);
return Promise.resolve(view);
}
@@ -260,6 +267,10 @@ export class MockHub implements HubPort {
return Promise.resolve({ summary: "mock migration complete", imported: 0, warnings: 0, recognised: true, skipped: [] });
}
importNativeSession(_content: unknown, _workspace: string) {
return Promise.resolve({ path: `/sessions/imported-${++this.seq}.jsonl` });
}
hostCapabilities() {
return Promise.resolve({ pickFolder: true, addWorkspace: true });
}
@@ -350,6 +361,12 @@ export class MockHub implements HubPort {
const held = this.ports.get(rt.id);
if (held) return held;
const port = new MockPort();
let settings = this.settings;
if (rt.host) {
settings = this.remoteSettings.get(rt.host) ?? new MockPort();
this.remoteSettings.set(rt.host, settings);
}
port.shareAgentPresetsFrom(settings,rt.root);
port.machine = this.machine;
this.ports.set(rt.id, port);
return port;
+10
View File
@@ -1,4 +1,6 @@
import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
import type { AgentComposition, AgentPresetVersion, AgentPresetCatalog, CompositionSummary } from "./composition";
export type * from "./composition";
import { DeliveryError, HttpError } from "./http_error";
import type { DeliveryFault } from "./http_error";
@@ -304,6 +306,14 @@ export interface AgentPort {
// Read by the kernel's background sweep on each pass; no rebuild.
saveAutoArchive(s: Pick<AutoArchiveSettings, "enabled" | "days">): Promise<AutoArchiveSettings>;
persona(): Promise<PersonaSettings>;
agentPresets(): Promise<AgentPresetCatalog>;
saveAgentPreset(definition: AgentComposition, expectedRevision: string): Promise<AgentPresetVersion>;
deleteAgentPreset(id: string, revision: string): Promise<void>;
setDefaultAgentPreset(id: string, workspace: boolean): Promise<void>;
exportAgentPreset(id: string): Promise<AgentComposition>;
importAgentPreset(content: AgentComposition): Promise<{definition:AgentComposition;diagnostics:AgentPresetCatalog["diagnostics"]}>;
agentComposition(): Promise<CompositionSummary | null>;
selectAgentComposition(id: string): Promise<CompositionSummary>;
// The persona is part of the cache-stable prefix, composed once at boot:
// saving is written now and read by the next session, not this one's runtime.
savePersonaSettings(s: PersonaDraft): Promise<PersonaSettings>;
@@ -1,4 +1,5 @@
import type { ModelMode } from "./model";
import type { CompositionSummary } from "./composition";
import type { StoredAttachment, Via } from "./wire";
// A conversation and the states it passes through: what is running, what it
// cost, and the points it can be wound back to.
@@ -223,6 +224,8 @@ export interface Decision {
}
export interface SessionStatus {
agentComposition?:CompositionSummary|null;
agentCompositionLocked?:boolean;
// Whether the current model reads images at all, and whether that answer was
// ever given: a relay forwards models nothing here has a label for, and an
// undeclared one is not the same claim as a text-only one.
@@ -1,10 +1,19 @@
import { SseShell } from "./sse_shell";
import type { AgentComposition, AgentPresetVersion, AgentPresetCatalog, CompositionSummary } from "./composition";
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings, PersonaDraft, PersonaSettings } from "./boundary";
// Where the agent may reach: the permission rules a call is matched against and
// the sandbox the shell runs in.
export class SseBoundary extends SseShell {
agentPresets() { return this.get<AgentPresetCatalog>("/agent-presets"); }
saveAgentPreset(definition: AgentComposition, expectedRevision: string) { return this.post0<AgentPresetVersion>("/agent-presets", { definition, expectedRevision }); }
deleteAgentPreset(id: string, revision: string) { return this.del(`/agent-presets/${encodeURIComponent(id)}?revision=${encodeURIComponent(revision)}`); }
setDefaultAgentPreset(id: string, workspace: boolean) { return this.post0<void>("/agent-presets/default", { id, workspace }); }
exportAgentPreset(id: string) { return this.get<AgentComposition>(`/agent-presets/${encodeURIComponent(id)}/export`); }
importAgentPreset(content: AgentComposition) { return this.post0<{definition:AgentComposition;diagnostics:AgentPresetCatalog["diagnostics"]}>("/agent-presets/import", { content }); }
agentComposition() { return this.get<CompositionSummary | null>("/agent-composition"); }
selectAgentComposition(id: string) { return this.post0<CompositionSummary>("/agent-composition", { id }); }
adjudications() {
return this.get<Adjudications>("/adjudications");
}
+18
View File
@@ -5588,3 +5588,21 @@
transition-duration: .01ms !important;
}
}
.composition-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-block: 12px; }
.composition-choice { flex: 1; min-width: 180px; }
.composition-editor { display: grid; gap: 12px; margin-block: 16px; }
.composition-editor input, .composition-editor select, .composition-choice select { min-width: 0; max-width: 100%; }
.composition-modules { border: 1px solid var(--border); border-radius: 8px; padding: 12px; min-width: 0; }
.composition-modules legend { font-weight: 600; padding-inline: 4px; }
.composition-module { margin-block: 8px; }
.composition-module .in { flex: 1; min-width: 100px; }
.composition-picker { display: inline-flex; align-items: center; min-width: 0; }
.composition-picker select { max-width: 150px; }
.composition-editor :is(input, select), .composition-choice select {
width: 100%; min-height: 34px; border: 1px solid var(--border); border-radius: var(--r-sm);
background: var(--raised); color: var(--text); padding: 6px 9px; font: var(--w-reg) var(--fs-ui)/1.5 var(--ui);
}
.composition-editor :is(input, select):focus-visible, .composition-choice select:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.composition-editor :is(input, select):disabled { opacity: .65; }
.composition-editor h4 { margin: 10px 12px 0; font-size: var(--fs-ui); }
.composition-picker { flex-wrap: wrap; gap: 4px; }
@@ -0,0 +1,21 @@
// @vitest-environment jsdom
import {cleanup,render,screen} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {afterEach,expect,it,vi} from "vitest";
import {MockPort} from "../port/mock";
import {AgentCompositionPicker} from "./AgentCompositionPicker";
import {CompositionNewContext} from "./CompositionNewContext";
afterEach(cleanup);
it("keeps started conversations locked and opens the chosen composition in a new conversation",async()=>{
const port=new MockPort();const create=vi.fn();const select=vi.spyOn(port,"selectAgentComposition");
const user=userEvent.setup();
render(<CompositionNewContext.Provider value={create}><AgentCompositionPicker port={port} locked running={false} onChanged={()=>{}} onSettings={()=>{}} onError={()=>{}}/></CompositionNewContext.Provider>);
const picker=await screen.findByRole<HTMLSelectElement>("combobox",{name:"Agent 组合"});
expect(picker.disabled).toBe(true);
await user.selectOptions(screen.getByRole("combobox",{name:"用其他组合新建会话"}),"builtin:review");
await user.click(screen.getByRole("button",{name:"新建会话"}));
expect(create).toHaveBeenCalledWith("builtin:review");
expect(select).not.toHaveBeenCalled();
});
@@ -0,0 +1,35 @@
import { useContext, useEffect, useState } from "react";
import { CompositionNewContext } from "./CompositionNewContext";
import type { AgentPort } from "../port/port";
import type { AgentPresetCatalog, CompositionSummary } from "../port/composition";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
export function AgentCompositionPicker({ port, binding,locked:statusLocked,running, onChanged, onSettings, onError }: { port: AgentPort; binding?:CompositionSummary|null;locked?:boolean; running: boolean; onChanged: () => void; onSettings: () => void; onError: (e: unknown) => void }) {
const [catalog, setCatalog] = useState<AgentPresetCatalog | null>(null);
const [current, setCurrent] = useState<CompositionSummary | null>(null);
const [busy, setBusy] = useState(false);
const [next, setNext] = useState("");
const createNew = useContext(CompositionNewContext);
useEffect(() => {
let active = true;
Promise.all([port.agentPresets(), port.agentComposition()]).then(([list, binding]) => { if (active) { setCatalog(list); setCurrent(binding); } }).catch(() => {});
return () => { active = false; };
}, [port, running,binding?.digest,binding?.started]);
if (!catalog) return null;
const selected=catalog.presets.find((v)=>v.definition.id===current?.id)?.definition;
const locked = running || statusLocked || !!current?.started;
return <span className="composition-picker">
<select className="mode" aria-label={t("Agent 组合")} data-action-change="agent-composition.select" disabled={busy || locked} value={current?.id ?? ""} title={locked ? t("该会话已开始,组合保持原版本。请用其他组合新建会话。") : selected?.description?t(selected.description):t("首次提交前可切换 Agent 组合")} onChange={(e) => {
const id = e.target.value; setBusy(true);
port.selectAgentComposition(id).then((binding) => { setCurrent(binding); onChanged(); }).catch((e) => onError(reason(e))).finally(() => setBusy(false));
}}>
{!current && <option value="">{t("原有会话配置")}</option>}
{current && !catalog.presets.some((v) => v.definition.id === current.id) && <option value={current.id}>{current.name}</option>}
{catalog.presets.map((v) => <option key={v.definition.id} value={v.definition.id}>{v.definition.id.startsWith("builtin:")?t(v.definition.name):v.definition.name} · {v.definition.id.split(":")[0]}</option>)}
</select>
{locked && createNew && <><select className="mode" aria-label={t("用其他组合新建会话")} data-action-change="agent-composition.new-choice" value={next} onChange={(e) => setNext(e.target.value)}><option value="">{t("用其他组合新建会话")}</option>{catalog.presets.map((v) => <option key={v.definition.id} value={v.definition.id}>{v.definition.id.startsWith("builtin:")?t(v.definition.name):v.definition.name}</option>)}</select><button className="mode" type="button" data-action="agent-composition.new" disabled={!next} onClick={() => createNew(next)}>{t("新建会话")}</button></>}
{current?.unavailable && <span role="alert" title={current.unavailable}>{t("组合资源不可用,当前会话仅可查看历史。")}</span>}
<button type="button" className="mode plain" data-action="agent-composition.manage" onClick={onSettings} aria-label={t("管理 Agent 预设")}>⋯</button>
</span>;
}
@@ -0,0 +1,54 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, it, expect, vi } from "vitest";
import { MockPort } from "../port/mock";
import { AgentPresets } from "./AgentPresets";
afterEach(cleanup);
describe("Agent preset management", () => {
it("copies a built-in, saves literal fields and keeps the current session revision", async () => {
const user = userEvent.setup();
const port = new MockPort();
render(<AgentPresets port={port} />);
await user.click(await screen.findByRole("button", { name: "复制为自定义预设" }));
await user.clear(screen.getByLabelText("预设名称"));
await user.type(screen.getByLabelText("预设名称"), "My agent");
await user.type(screen.getByLabelText("系统提示词开头"), "Literal prefix");
await user.type(screen.getByLabelText("第一方指令之后"), "Literal suffix");
await user.click(screen.getByRole("switch", { name: "用前缀替换整个系统提示词(complete)" }));
await user.click(screen.getByRole("switch", { name: "注入运行环境与离线提示" }));
await user.click(screen.getByRole("button", { name: "保存预设" }));
expect((await screen.findByRole("status")).textContent).toContain("新会话使用新版");
expect((await port.agentPresets()).presets.some((v) => v.definition.name === "My agent")).toBe(true);
expect((await port.agentComposition())?.id).toBe("builtin:general");
expect((await port.agentPresets()).presets.find((v)=>v.definition.name==="My agent")?.definition.persona).toEqual({prefix:"Literal prefix",suffix:"Literal suffix",complete:true,include_runtime_context:false});
});
it("keeps an unsaved draft after a conflict", async () => {
const user = userEvent.setup();
const port = new MockPort();
vi.spyOn(port, "saveAgentPreset").mockRejectedValue(new Error("conflict"));
render(<AgentPresets port={port} />);
await user.click(await screen.findByRole("button", { name: "复制为自定义预设" }));
await user.type(screen.getByLabelText("预设名称"), " draft");
await user.click(screen.getByRole("button", { name: "保存预设" }));
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
expect((screen.getByLabelText("预设名称") as HTMLInputElement).value).toContain("draft");
});
it("does not save an earlier parameter value when the visible JSON is invalid", async () => {
const user=userEvent.setup();const port=new MockPort();const save=vi.spyOn(port,"saveAgentPreset");
render(<AgentPresets port={port}/>);
await user.click(await screen.findByRole("button",{name:"复制为自定义预设"}));
await user.selectOptions(screen.getByLabelText("MCP 服务 选择方式"),"select");
const group=screen.getByRole("group",{name:"MCP 服务"});
await user.click(group.querySelector("button")!);
await user.type(screen.getByLabelText("MCP 服务 ID 1"),"docs");
await user.clear(screen.getByLabelText("MCP 服务 独立参数 1"));
await user.type(screen.getByLabelText("MCP 服务 独立参数 1"),"invalid");
expect(screen.getByRole<HTMLButtonElement>("button",{name:"保存预设"}).disabled).toBe(true);
expect(save).not.toHaveBeenCalled();
await user.click(group.querySelector(".composition-module button")!);
await waitFor(()=>expect(screen.getByRole<HTMLButtonElement>("button",{name:"保存预设"}).disabled).toBe(false));
});
});
@@ -0,0 +1,83 @@
import { useEffect, useState } from "react";
import type { AgentPort } from "../port/port";
import type { AgentComposition, AgentPresetCatalog, AgentPresetVersion, CompositionSummary } from "../port/composition";
import { builtinCompositions } from "../port/composition";
import { t } from "../i18n";
import { reason,say } from "../i18n/kernel";
import { Group } from "./Group";
import { CompositionEditor } from "./CompositionEditor";
export function AgentPresets({ port }: { port: AgentPort }) {
const [catalog, setCatalog] = useState<AgentPresetCatalog | null>(null);
const [version, setVersion] = useState<AgentPresetVersion | null>(null);
const [draft, setDraft] = useState<AgentComposition | null>(null);
const [current, setCurrent] = useState<CompositionSummary | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [saved, setSaved] = useState("");
const [deleting, setDeleting] = useState(false);
const [exported, setExported] = useState("");
const [valid,setValid] = useState(true);
const [editorEpoch,setEditorEpoch] = useState(0);
const [importDiagnostics,setImportDiagnostics] = useState<AgentPresetCatalog["diagnostics"]>([]);
const select = (v: AgentPresetVersion) => { setVersion(v); setDraft(structuredClone(v.definition)); setSaved(""); setError(""); setDeleting(false); setExported(""); setImportDiagnostics([]);setValid(true);setEditorEpoch((n)=>n+1); };
const load = async () => {
try {
const [next, binding] = await Promise.all([port.agentPresets(), port.agentComposition()]);
setCatalog(next); setCurrent(binding);
const v = next.presets.find((v) => v.definition.id === binding?.id) ?? next.presets.find((v) => v.definition.id === next.default) ?? next.presets[0];
if (v) select(v);
} catch (e) { setError(reason(e)); }
};
useEffect(() => { void load(); }, [port]);
const perform = async (work: () => Promise<void>) => { setBusy(true); setError(""); setSaved(""); try { await work(); } catch (e) { setError(reason(e)); } finally { setBusy(false); } };
const copy = (base: AgentComposition) => {
const ids = new Set(catalog?.presets.map((v) => v.definition.id));
let id = "user:custom"; let n = 1;
while (ids.has(id)) id = `user:custom-${n++}`;
setVersion(null); setDraft({ ...structuredClone(base), id, name: `${base.id.startsWith("builtin:") ? t(base.name) : base.name} ${t("副本")}` }); setSaved(""); setError(""); setDeleting(false);setImportDiagnostics([]);setValid(true);setEditorEpoch((n)=>n+1);
};
const immutable = draft?.id.startsWith("builtin:") ?? true;
const changed = draft && (!version || JSON.stringify(draft) !== JSON.stringify(version.definition));
return <Group id="agent-presets" title={t("Agent 预设")} hint={t("为不同会话组合人设、工具、技能与扩展。模型保持独立;已开始的会话保留原版本。") }>
<div className="box">
{!catalog ? <><p>{error || t("读取预设中…")}</p><button className="act" data-action="agent-preset.reload" onClick={() => void load()}>{t("重新加载")}</button></> : <>
<div className="composition-toolbar">
<label className="pers-field composition-choice"><span className="lb">{t("查看预设")}</span><select aria-label={t("查看预设")} data-action-change="agent-preset.view" disabled={busy} value={version?.definition.id ?? ""} onChange={(e) => { const v = catalog.presets.find((v) => v.definition.id === e.target.value); if (v) select(v); }}>
{!version && <option value="">{t("未保存的预设")}</option>}
{catalog.presets.map((v) => <option key={v.definition.id} value={v.definition.id}>{v.definition.id.startsWith("builtin:") ? t(v.definition.name) : v.definition.name} · {v.definition.id}{v.definition.id === catalog.default ? ` · ${t("默认")}` : ""}</option>)}
</select></label>
<button className="act" data-action="agent-preset.create" disabled={busy} onClick={() => copy(builtinCompositions()[0])}>{t("新建预设")}</button>
<button className="act" data-action="agent-preset.copy" disabled={!draft || busy} onClick={() => draft && copy(draft)}>{t("复制为自定义预设")}</button>
<button className="act" data-action="agent-preset.reload" disabled={busy} onClick={() => void load()}>{t("重新加载")}</button>
</div>
{current && <p className="ds">{t("当前会话")}: {current.id.startsWith("builtin:") ? t(current.name) : current.name} · {current.revision.slice(0, 12)} {current.started ? t("已锁定") : t("首次提交前可切换")}{version && current.id === version.definition.id && current.revision !== version.revision ? ` · ${t("当前会话使用旧版")}` : ""}</p>}
{draft && <>
{!version && <label className="pers-field"><span className="lb">{t("预设标识")}</span><div className="composition-toolbar"><select aria-label={t("存放范围")} data-action-change="agent-preset.scope" value={draft.id.split(":")[0]} onChange={(e) => setDraft({ ...draft, id: `${e.target.value}:${draft.id.split(":")[1]}` })}><option value="user">{t("个人")}</option><option value="project">{t("项目共享")}</option></select><input className="in" aria-label={t("预设标识")} data-action-change="agent-preset.id" value={draft.id.split(":")[1]} onChange={(e) => setDraft({ ...draft, id: `${draft.id.split(":")[0]}:${e.target.value}` })} /></div></label>}
{immutable && <p className="ds">{t("内置预设只读,复制后即可编辑。")}</p>}
<CompositionEditor key={editorEpoch} draft={draft} modules={catalog.modules} disabled={busy || immutable} onValidityChange={setValid} onChange={(d) => { setDraft(d); setSaved(""); }} />
<div className="composition-toolbar">
<button className="act" data-action="agent-preset.save" disabled={busy || immutable || !changed || !valid} onClick={() => void perform(async () => {
const result = await port.saveAgentPreset(draft, version?.revision ?? "");
setCatalog(await port.agentPresets()); setVersion(result); setDraft(structuredClone(result.definition)); setSaved(t("已保存,新会话使用新版"));
})}>{t("保存预设")}</button>
<button className="act" data-action="agent-preset.default" disabled={busy || !version || !!changed || draft.id.startsWith("project:")} onClick={() => void perform(async () => { await port.setDefaultAgentPreset(draft.id, false); setCatalog(await port.agentPresets()); setSaved(t("已设为个人默认预设")); })}>{t("设为个人默认")}</button>
<button className="act" data-action="agent-preset.workspace-default" disabled={busy || !version || !!changed} onClick={() => void perform(async () => { await port.setDefaultAgentPreset(draft.id, true); setCatalog(await port.agentPresets()); setSaved(t("已设为当前项目默认预设")); })}>{t("设为项目默认")}</button>
<button className="act" data-action="agent-preset.export" disabled={busy || !version || !!changed} onClick={() => void perform(async () => setExported(JSON.stringify(await port.exportAgentPreset(draft.id), null, 2)))}>{t("导出文件")}</button>
{!immutable && version && <button className="act" data-action="agent-preset.delete-preview" disabled={busy} onClick={() => setDeleting(true)}>{t("删除预设")}</button>}
</div>
{deleting && version && <div className="find"><p>{t("删除后不再供新会话选择,已有会话保留绑定版本。")}</p><button className="act" data-action="agent-preset.delete" onClick={() => void perform(async () => { await port.deleteAgentPreset(version.definition.id, version.revision); await load(); })}>{t("确认删除")}</button><button className="act" data-action="agent-preset.delete-cancel" onClick={() => setDeleting(false)}>{t("取消")}</button></div>}
</>}
<label className="pers-field"><span className="lb">{t("导入预设文件(先预览,再保存)")}</span><input type="file" accept="application/json,.json" data-action-change="agent-preset.import" disabled={busy} onChange={(e) => {
const file = e.target.files?.[0]; if (!file) return;
void perform(async () => { if(file.size>4*1024*1024) throw {code:"agent_composition.invalid"};const imported = await port.importAgentPreset(JSON.parse(await file.text()) as AgentComposition); copy(imported.definition);setImportDiagnostics(imported.diagnostics); setSaved(t("导入预览已就绪,检查模块后保存。")); });
}} /></label>
{exported && <a className="act" data-action="agent-preset.download" download="agent-preset.json" href={`data:application/json;charset=utf-8,${encodeURIComponent(exported)}`}>{t("下载预设 JSON")}</a>}
{catalog.diagnostics.map((d, i) => <p className="ds" key={i}>{d.module}: {d.detail}</p>)}
{importDiagnostics.map((d,i)=><p className="ds" key={`import-${i}`}>{d.module}: {say({code:d.code})}</p>)}
</>}
{saved && <p className="find" role="status">{saved}</p>}
{error && <p className="find" data-lvl="err" role="alert">{error}</p>}
</div>
</Group>;
}
+5 -4
View File
@@ -1,4 +1,5 @@
import { useBranchSession } from "./useBranchSession";
import { CompositionNewContext } from "./CompositionNewContext";
import { keepSame } from "./stable";
import { usePaneCallbacks } from "./usePaneCallbacks";
import { watchTreeWake } from "./treewake";
@@ -260,10 +261,10 @@ export function App({ hub }: { hub: HubPort }) {
);
const openRemotePane = useCallback(
async (host: string, workspace?: string, sessionPath?: string) => {
async (host: string, workspace?: string, sessionPath?: string, agentComposition?:string) => {
setOpening((n) => n + 1);
try {
const view = await hub.openRemote({ host, workspace, sessionPath });
const view = await hub.openRemote({ host, workspace, sessionPath, agentComposition });
await reloadPanes();
sw.cancel();
setActive(view.id);
@@ -559,7 +560,7 @@ export function App({ hub }: { hub: HubPort }) {
}
return (
<div
<CompositionNewContext.Provider value={(id) => {const rt=runtimes.find((v)=>v.id===active);void (rt?.host?openRemotePane(rt.host,rt.root,undefined,id):openPane({root:newSessionRoot,agentComposition:id})).catch(fail);}}><div
className="app"
data-run={report.run}
data-rail={rail ? "on" : "off"}
@@ -793,6 +794,6 @@ export function App({ hub }: { hub: HubPort }) {
)}
<RailToggle rail={rail} onRail={() => setRail((v) => !v)} />
<StopHint armed={stopArmed} />
</div>
</div></CompositionNewContext.Provider>
);
}
+2 -2
View File
@@ -8,7 +8,7 @@ import { Picker } from "./Menu";
import { BranchChip } from "./BranchChip";
import { WorktreeChip } from "./WorktreeChip";
import { useWorktreeSetup } from "./useWorktreeSetup";
import { Policy } from "./Policy";
import { SessionControls } from "./SessionControls";
import { chosenGroups, groupHeadings, modelMenu, narrowToGroups } from "./modelmenu";
import { ModelGroupChips } from "./ModelGroupChips";
import { effortMenu, effortReading, effortsFor, forcesThinkingFor, routeEffortPick } from "./effort";
@@ -704,7 +704,7 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
<span className="lb">{t("执行计划中")}</span>
</span>
)}
<Policy port={port} status={status} onChanged={onChanged} onBoundary={() => onSettings("tools:sandbox")} />
<SessionControls port={port} status={status} running={running} onChanged={onChanged} onSettings={onSettings} onError={onError} />
<div className="studio-model-group">
<Picker
wrapClassName="studio-model-control"
@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import { t } from "../i18n";
import type { AgentComposition, AgentPresetCatalog,CompositionModule, CompositionKind, CompositionSelection } from "../port/composition";
import { COMPOSITION_KINDS } from "../port/composition";
import { Switch } from "./Switch";
const labels: Record<CompositionKind, string> = { tools: "工具", skills: "技能", mcp: "MCP 服务", extensions: "扩展", hooks: "自动化 Hook", commands: "斜杠命令" };
export function CompositionEditor({ draft, modules,disabled, onChange, onValidityChange }: { draft: AgentComposition; modules:AgentPresetCatalog["modules"];disabled: boolean; onChange: (d: AgentComposition) => void; onValidityChange: (valid:boolean)=>void }) {
const [valid, setValid] = useState<Record<string,boolean>>({});
useEffect(() => onValidityChange(Object.values(valid).every(Boolean)), [valid,onValidityChange]);
const persona = draft.persona;
const updatePersona = (patch: Partial<AgentComposition["persona"]>) => onChange({ ...draft, persona: { ...persona, ...patch } });
return <div className="composition-editor">
<label className="pers-field"><span className="lb">{t("预设名称")}</span><input className="in" aria-label={t("预设名称")} data-action-change="agent-preset.name" disabled={disabled} value={draft.id.startsWith("builtin:") ? t(draft.name) : draft.name} onChange={(e) => onChange({ ...draft, name: e.target.value })} /></label>
<label className="pers-field"><span className="lb">{t("预设说明")}</span><input className="in" data-action-change="agent-preset.description" disabled={disabled} value={draft.id.startsWith("builtin:") ? t(draft.description) : draft.description} onChange={(e) => onChange({ ...draft, description: e.target.value })} /></label>
<h4>{t("系统人设")}</h4>
<label className="pers-field"><span className="lb">{t("系统提示词开头")}</span><textarea aria-label={t("系统提示词开头")} data-action-change="persona.prefix" rows={4} maxLength={32768} disabled={disabled} value={persona.prefix} onChange={(e) => updatePersona({ prefix: e.target.value })} /></label>
<label className="pers-field"><span className="lb">{t("第一方指令之后")}</span><textarea aria-label={t("第一方指令之后")} data-action-change="persona.suffix" rows={3} maxLength={32768} disabled={disabled} value={persona.suffix} onChange={(e) => updatePersona({ suffix: e.target.value })} /></label>
<div className="lrow"><span className="tx"><span className="lb">{t("用前缀替换整个系统提示词(complete)")}</span><span className="ds">{t("仅保留前缀文本,工具仍受组合和现有权限约束。")}</span></span><Switch data-action="persona.complete" label={t("用前缀替换整个系统提示词(complete)")} on={persona.complete} busy={disabled || (!persona.complete && !persona.prefix.trim())} onClick={() => updatePersona({ complete: !persona.complete })} /></div>
<div className="lrow"><span className="tx"><span className="lb">{t("注入运行环境与离线提示")}</span></span><Switch data-action="persona.runtime-context" label={t("注入运行环境与离线提示")} on={persona.include_runtime_context} busy={disabled} onClick={() => updatePersona({ include_runtime_context: !persona.include_runtime_context })} /></div>
{COMPOSITION_KINDS.map((kind) => <ModuleEditor key={`${draft.id}:${kind}`} kind={kind} choices={modules?.[kind] ?? []} value={draft[kind]} disabled={disabled} onValidityChange={(ok)=>setValid((prev)=>({...prev,[kind]:ok}))} onChange={(value) => onChange({ ...draft, [kind]: value })} />)}
</div>;
}
function ModuleEditor({ kind, choices,value, disabled, onChange, onValidityChange }: { kind: CompositionKind; choices:CompositionModule[];value: CompositionSelection; disabled: boolean; onChange: (value: CompositionSelection) => void; onValidityChange:(valid:boolean)=>void }) {
const [jsonErrors, setJsonErrors] = useState<Record<number, string>>({});
const [rawParameters,setRawParameters] = useState<Record<number,string>>({});
const validate = (errors:Record<number,string>) => { setJsonErrors(errors);onValidityChange(Object.values(errors).every((v)=>!v)); };
const remove = (index:number) => {
const shift = <T,>(source:Record<number,T>):Record<number,T> => Object.fromEntries(Object.entries(source).filter(([key])=>Number(key)!==index).map(([key,v])=>[Number(key)>index?Number(key)-1:Number(key),v]));
validate(shift(jsonErrors));setRawParameters(shift(rawParameters));onChange({...value,members:value.members.filter((_,i)=>i!==index)});
};
return <fieldset className="composition-modules" disabled={disabled}>
<legend>{t(labels[kind])}</legend>
<select aria-label={`${t(labels[kind])} ${t("选择方式")}`} data-action-change="agent-preset.module-mode" data-target={kind} value={value.mode} onChange={(e) => { validate({});setRawParameters({});onChange({ mode: e.target.value as "inherit" | "select", members: [] }); }}>
<option value="inherit">{t("创建会话时继承当前配置")}</option><option value="select">{t("仅使用所选项(空列表表示禁用)")}</option>
</select>
{value.mode === "select" && <>
{choices.length>0 && <select aria-label={`${t(labels[kind])} ${t("添加模块")}`} data-action-change="agent-preset.module-choose" data-target={kind} value="" onChange={(e)=>{if(e.target.value) onChange({...value,members:[...value.members,{id:e.target.value}]});}}><option value="">{t("添加模块")}</option>{choices.filter((m)=>!value.members.some((s)=>s.id===m.id)).map((m)=><option key={m.id} value={m.id}>{m.id}</option>)}</select>}
{value.members.map((module, index) => <div className="composition-module" key={index}>
<div className="composition-toolbar"><input className="in" aria-label={`${t(labels[kind])} ID ${index + 1}`} data-action-change="agent-preset.module-id" data-target={kind} value={module.id} onChange={(e) => onChange({ ...value, members: value.members.map((m, i) => i === index ? { ...m, id: e.target.value } : m) })} /><button type="button" className="act" data-action="agent-preset.module-remove" data-target={kind} onClick={() => remove(index)}>{t("移除")}</button></div>
{(kind === "mcp" || kind === "extensions") && <label className="pers-field"><span className="ds">{t("独立参数(JSON);env 和 headers 仅填写 ${NAME} 引用")}</span><textarea aria-label={`${t(labels[kind])} ${t("独立参数")} ${index + 1}`} data-action-change="agent-preset.module-parameters" data-target={kind} rows={3} value={rawParameters[index]??JSON.stringify(module.parameters ?? {}, null, 2)} onChange={(e) => {
setRawParameters({...rawParameters,[index]:e.target.value});
try {
const parsed: unknown = JSON.parse(e.target.value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("object required");
validate({ ...jsonErrors, [index]: "" });
onChange({ ...value, members: value.members.map((m, i) => i === index ? { ...m, parameters: parsed as Record<string, unknown> } : m) });
} catch { validate({ ...jsonErrors, [index]: t("参数需要有效的 JSON 对象") }); }
}} />{jsonErrors[index] && <span role="alert">{jsonErrors[index]}</span>}</label>}
</div>)}
<button type="button" className="act" data-action="agent-preset.module-add" data-target={kind} onClick={() => onChange({ ...value, members: [...value.members, { id: "" }] })}>{t("添加模块")}</button>
</>}
</fieldset>;
}
@@ -0,0 +1,3 @@
import { createContext } from "react";
export const CompositionNewContext = createContext<((id: string) => void) | null>(null);
@@ -0,0 +1,22 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { MockHub } from "../port/mock_hub";
import { NativeSessionImport } from "./NativeSessionImport";
afterEach(cleanup);
it("passes the complete native envelope to the backend and refreshes history", async () => {
const hub = new MockHub();
const imported = vi.spyOn(hub,"importNativeSession");
const refresh = vi.fn();
const archive = {format:"reasonix-native-session",version:2,records:[{role:"system",agentComposition:{digest:"unchanged"}}]};
const file = new File([JSON.stringify(archive)],"session.json",{type:"application/json"});
Object.defineProperty(file,"text",{value:async()=>JSON.stringify(archive)});
render(<NativeSessionImport hub={hub} workspace="/workspace" onImported={refresh}/>);
await userEvent.upload(screen.getByLabelText("选择会话 JSON 文件"),file);
expect((await screen.findByRole("status")).textContent).toContain("绑定版本保持不变");
expect(imported).toHaveBeenCalledWith(archive,"/workspace");
expect(refresh).toHaveBeenCalledOnce();
});
@@ -0,0 +1,27 @@
import { useState } from "react";
import type { HubPort } from "../port/hub";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
export function NativeSessionImport({ hub, workspace, onImported }: { hub: HubPort; workspace: string; onImported: () => void }) {
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
return <section className="grp">
<h3 className="lbl">{t("导入原生会话")}</h3>
<p className="hint">{t("保留组合版本与资源;导入不会安装程序或授予权限。缺少授权的会话仅可查看历史。")}</p>
<label>{t("选择会话 JSON 文件")}<input type="file" accept="application/json,.json" data-action-change="session.import-native" disabled={busy || !workspace} onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
setBusy(true); setError(""); setMessage("");
void (async () => {
if (file.size > 192 * 1024 * 1024) throw new Error(t("会话文件超过大小限制。"));
await hub.importNativeSession(JSON.parse(await file.text()), workspace);
setMessage(t("会话已导入,绑定版本保持不变。"));
onImported();
})().catch((e) => setError(reason(e))).finally(() => setBusy(false));
}} /></label>
{message && <p role="status">{message}</p>}
{error && <p role="alert">{error}</p>}
</section>;
}
@@ -1,131 +0,0 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Persona } from "./Persona";
import { HttpError, type AgentPort } from "../port/port";
import type { PersonaDraft, PersonaSettings } from "../port/boundary";
afterEach(cleanup);
const base: PersonaSettings = {
prefix: "", suffix: "", complete: false, include_runtime_context: true,
path: "/u/config.toml",
presets: [
{ id: "engineer", prefix: "Rigorous engineer prefix.", suffix: "", complete: true, include_runtime_context: true },
{ id: "concise", prefix: "", suffix: "Be terse.", complete: false, include_runtime_context: false },
],
};
function portWith(initial: PersonaSettings = base) {
return {
persona: vi.fn(async () => initial),
savePersonaSettings: vi.fn(async (s: PersonaDraft) => ({ ...initial, ...s })),
} as unknown as AgentPort & { savePersonaSettings: ReturnType<typeof vi.fn> };
}
const field = (name: string) => screen.findByRole<HTMLTextAreaElement>("textbox", { name });
const saveButton = () => screen.getByRole<HTMLButtonElement>("button", { name: "保存" });
const now = (container: HTMLElement) => container.querySelector(".grp .grp-hd .now")?.textContent ?? null;
describe("the system persona page", () => {
it("renders the presets the kernel serves and fills both boxes from one", async () => {
const { container } = render(<Persona port={portWith()} />);
expect(await screen.findByRole("button", { name: "严谨工程师" })).toBeTruthy();
expect(screen.getByRole("button", { name: "简洁" })).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "严谨工程师" }));
expect((await field("系统提示词开头")).value).toBe("Rigorous engineer prefix.");
expect((await field("第一方指令之后")).value).toBe("");
expect(screen.getByRole("switch", { name: "用前缀替换整个系统提示词(complete)" }).getAttribute("aria-checked")).toBe("true");
expect(now(container)).toBe("严谨工程师");
await userEvent.click(screen.getByRole("button", { name: "简洁" }));
expect((await field("系统提示词开头")).value).toBe("");
expect((await field("第一方指令之后")).value).toBe("Be terse.");
// The runtime-context switch shows the inverse of the field it writes.
expect(screen.getByRole("switch", { name: "不注入可选运行时上下文" }).getAttribute("aria-checked")).toBe("true");
});
it("reads 自定义 once the applied preset is edited", async () => {
const { container } = render(<Persona port={portWith()} />);
await userEvent.click(await screen.findByRole("button", { name: "严谨工程师" }));
expect(now(container)).toBe("严谨工程师");
await userEvent.type(await field("系统提示词开头"), " Also be kind.");
expect(now(container)).toBe("自定义");
});
it("saves all four fields and says only new sessions read them", async () => {
const port = portWith();
render(<Persona port={port} />);
const prefix = await field("系统提示词开头");
expect(saveButton().disabled).toBe(true);
await userEvent.type(prefix, "Lead with evidence.");
await userEvent.type(await field("第一方指令之后"), "Stay terse.");
await userEvent.click(screen.getByRole("switch", { name: "不注入可选运行时上下文" }));
expect(saveButton().disabled).toBe(false);
await userEvent.click(saveButton());
expect(port.savePersonaSettings).toHaveBeenCalledWith({
prefix: "Lead with evidence.",
suffix: "Stay terse.",
complete: false,
include_runtime_context: false,
});
expect(await screen.findByText("已保存,新会话生效")).toBeTruthy();
expect(screen.getByText("/u/config.toml")).toBeTruthy();
// Clean again: the same bytes would be a second no-op save.
expect(saveButton().disabled).toBe(true);
});
it("cannot turn complete on until there is a prefix", async () => {
render(<Persona port={portWith()} />);
const sw = (await screen.findByRole("switch", { name: "用前缀替换整个系统提示词(complete)" })) as HTMLButtonElement;
expect(sw.disabled).toBe(true);
expect(screen.getByText(/先填写前缀才能开启/)).toBeTruthy();
await userEvent.type(await field("系统提示词开头"), "You are someone.");
await waitFor(() => expect(sw.disabled).toBe(false));
await userEvent.click(sw);
expect(sw.getAttribute("aria-checked")).toBe("true");
});
it("refuses complete with an empty prefix without writing", async () => {
const port = portWith();
render(<Persona port={port} />);
await userEvent.click(await screen.findByRole("button", { name: "严谨工程师" }));
await userEvent.clear(await field("系统提示词开头"));
await userEvent.click(saveButton());
expect(port.savePersonaSettings).not.toHaveBeenCalled();
expect(await screen.findByText("启用「用前缀替换整个系统提示词」时,前缀不能为空。")).toBeTruthy();
});
it("shows the kernel's wording for the two persona refusals", async () => {
const port = portWith();
render(<Persona port={port} />);
await userEvent.type(await field("系统提示词开头"), "Something.");
port.savePersonaSettings.mockImplementationOnce(async () => {
throw new HttpError(400, "persona.complete_requires_prefix", {
code: "persona.complete_requires_prefix", params: { detail: "prefix is blank" },
});
});
await userEvent.click(saveButton());
expect(await screen.findByText(/complete 模式必须填写前缀/)).toBeTruthy();
port.savePersonaSettings.mockImplementationOnce(async () => {
throw new HttpError(400, "persona.too_large", {
code: "persona.too_large", params: { detail: "70 KiB" },
});
});
await userEvent.click(saveButton());
expect(await screen.findByText(/人设内容超出长度上限/)).toBeTruthy();
});
it("renders the empty state when the settings cannot be read", async () => {
const port = { persona: vi.fn(async () => { throw new Error("offline"); }), savePersonaSettings: vi.fn() } as unknown as AgentPort;
render(<Persona port={port} />);
expect(await screen.findByText("无法读取人设设置。")).toBeTruthy();
});
});
-242
View File
@@ -1,242 +0,0 @@
import { useEffect, useId, useState } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { AgentPort } from "../port/port";
import type { PersonaDraft, PersonaPreset, PersonaSettings } from "../port/boundary";
import { Group } from "./Group";
import { Switch } from "./Switch";
// The kernel refuses a prefix over 32 KiB and a total over 64 KiB; the box
// bounds the field it can see instead of letting a huge paste grow silently.
const LIMIT = 32768;
const BLANK: PersonaDraft = { prefix: "", suffix: "", complete: false, include_runtime_context: true };
const draftOf = (s: PersonaSettings): PersonaDraft => ({
prefix: s.prefix,
suffix: s.suffix,
complete: s.complete,
include_runtime_context: s.include_runtime_context,
});
const sameDraft = (a: PersonaDraft, b: PersonaDraft) =>
a.prefix === b.prefix && a.suffix === b.suffix && a.complete === b.complete &&
a.include_runtime_context === b.include_runtime_context;
const isPreset = (p: PersonaPreset, d: PersonaDraft) =>
sameDraft({ prefix: p.prefix, suffix: p.suffix, complete: p.complete, include_runtime_context: p.include_runtime_context }, d);
// Presets are served with their prompt text and a stable id; the words the
// page shows about them are this interface's, so they go through t() here.
function presetName(id: string): string {
switch (id) {
case "engineer": return t("严谨工程师");
case "concise": return t("简洁");
case "mentor": return t("教学导师");
case "critic": return t("严苛评审");
default: return id;
}
}
function presetAbout(id: string): string {
switch (id) {
case "engineer": return t("先验证再下结论,只做最少的正确改动,并说明剩余风险。");
case "concise": return t("回答简短直接,不铺垫,优先代码与要点。");
case "mentor": return t("像老师一样说明取舍与原理,把决定权留给你。");
case "critic": return t("以审查者的立场找问题:先指出失效方式和证据,再谈通过。");
default: return "";
}
}
// A persona leads the system prompt as one identity and trails the first-party
// guidance as one posture. It is composed once at boot, so this page writes a
// file and says plainly that only the sessions opened afterwards read it.
export function Persona({ port }: { port: AgentPort }) {
const [state, setState] = useState<PersonaSettings | null>(null);
const [draft, setDraft] = useState<PersonaDraft>(BLANK);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [saved, setSaved] = useState(false);
const prefixId = useId();
const suffixId = useId();
const adopt = (s: PersonaSettings) => {
setState(s);
setDraft(draftOf(s));
setSaved(false);
};
useEffect(() => {
port.persona().then(adopt).catch(() => setState(null));
}, [port]);
const change = (patch: Partial<PersonaDraft>) => {
setDraft((d) => ({ ...d, ...patch }));
setSaved(false);
setError("");
};
const save = async () => {
if (!state) return;
// The switch cannot be turned on without a prefix, but clearing the box
// after it was on is one keystroke away — the refusal has to live here.
if (draft.complete && !draft.prefix.trim()) {
setError(t("启用「用前缀替换整个系统提示词」时,前缀不能为空。"));
return;
}
setBusy(true);
setError("");
try {
adopt(await port.savePersonaSettings(draft));
setSaved(true);
} catch (e) {
setError(reason(e));
} finally {
setBusy(false);
}
};
const preset = state?.presets.find((p) => isPreset(p, draft)) ?? null;
const blank = !draft.prefix && !draft.suffix;
// A draft the presets do not describe is the user's own; saying so is the
// answer to "which of these am I looking at".
const now = !state ? undefined : preset ? presetName(preset.id) : blank ? undefined : t("自定义");
const changed = !!state && !sameDraft(draft, draftOf(state));
return (
<Group
id="persona"
title={t("系统人设")}
now={now}
hint={t("系统人设决定 agent 的身份:前缀写在系统提示词最前面,后缀写在内置指令之后。保存只影响之后新建的会话,当前会话保持它打开时的提示词。")}
>
{!state ? (
<div className="empty">{t("无法读取人设设置。")}</div>
) : (
<div className="box">
{state.presets.length > 0 && (
<>
<div className="seg" data-text role="group" aria-label={t("预设")}>
{state.presets.map((p) => (
<button
key={p.id}
type="button"
data-action="persona.preset"
data-value={p.id}
aria-pressed={preset?.id === p.id}
disabled={busy}
onClick={() => change({
prefix: p.prefix,
suffix: p.suffix,
complete: p.complete,
include_runtime_context: p.include_runtime_context,
})}
>
{presetName(p.id)}
</button>
))}
</div>
<p className="persona-name">
{preset
? presetAbout(preset.id)
: blank
? t("未应用预设;直接填写下面的两段即可。")
: t("已修改预设内容,按下面的字段保存。")}
</p>
</>
)}
<div className="pers-field">
<label className="lb" htmlFor={prefixId}>{t("系统提示词开头")}</label>
<span className="ds">{t("作为系统提示词的第一段,写在内置指令之前;留空表示不添加。")}</span>
<textarea
id={prefixId}
dir="auto"
rows={5}
maxLength={LIMIT}
spellCheck={false}
aria-label={t("系统提示词开头")}
data-action-change="persona.prefix"
value={draft.prefix}
disabled={busy}
onChange={(e) => change({ prefix: e.target.value })}
/>
</div>
<div className="pers-field">
<label className="lb" htmlFor={suffixId}>{t("第一方指令之后")}</label>
<span className="ds">{t("紧跟在内置指令之后、运行时上下文之前;留空表示不添加。")}</span>
<textarea
id={suffixId}
dir="auto"
rows={5}
maxLength={LIMIT}
spellCheck={false}
aria-label={t("第一方指令之后")}
data-action-change="persona.suffix"
value={draft.suffix}
disabled={busy}
onChange={(e) => change({ suffix: e.target.value })}
/>
</div>
<div className="lrow">
<span className="tx">
<span className="lb">{t("用前缀替换整个系统提示词(complete)")}</span>
<span className="ds">
{!draft.prefix.trim() && t("先填写前缀才能开启。")}
{t("开启后只用前缀作为整个系统提示词,其余内置指令、运行时上下文、记忆与技能不再写入(工具仍可用)。")}
</span>
</span>
<Switch
data-action="persona.complete"
on={draft.complete}
// Off can always be clicked; on needs a prefix to replace the
// prompt with.
busy={busy || (!draft.complete && !draft.prefix.trim())}
label={t("用前缀替换整个系统提示词(complete)")}
onClick={() => change({ complete: !draft.complete })}
/>
</div>
<div className="lrow">
<span className="tx">
<span className="lb">{t("不注入可选运行时上下文")}</span>
<span className="ds">{t("关闭后不注入探测到的运行环境与离线提示;指令、记忆与技能照常写入。")}</span>
</span>
<Switch
data-action="persona.runtime-context"
on={!draft.include_runtime_context}
busy={busy}
label={t("不注入可选运行时上下文")}
onClick={() => change({ include_runtime_context: !draft.include_runtime_context })}
/>
</div>
<div className="lrow">
<span className="tx">
<span className="lb">{t("保存")}</span>
<span className="ds">{t("写入用户配置,从新会话开始生效;当前会话保持它打开时的系统提示词。")}</span>
</span>
<button className="act" data-action="persona.save" disabled={busy || !changed} onClick={() => void save()}>
{t(busy ? "保存中…" : "保存")}
</button>
</div>
{saved && (
<div className="find" data-lvl="ok" role="status">
<span className="t">{t("已保存,新会话生效")}</span>
<span className="why"><code>{state.path}</code></span>
</div>
)}
{error && (
<div className="find" data-lvl="err" role="alert">
<span className="t">{t("操作未完成")}</span>
<span className="why">{error}</span>
</div>
)}
</div>
)}
</Group>
);
}
@@ -0,0 +1,10 @@
import type { AgentPort, SessionStatus } from "../port/port";
import { Policy } from "./Policy";
import { AgentCompositionPicker } from "./AgentCompositionPicker";
export function SessionControls({ port, status, running, onChanged, onSettings, onError }: { port: AgentPort; status: SessionStatus | null; running: boolean; onChanged: () => void; onSettings: (at: string) => void; onError: (e: unknown) => void }) {
return <>
<Policy port={port} status={status} onChanged={onChanged} onBoundary={() => onSettings("tools:sandbox")} />
<AgentCompositionPicker port={port} binding={status?.agentComposition} locked={status?.agentCompositionLocked} running={running} onChanged={onChanged} onSettings={() => onSettings("persona")} onError={onError} />
</>;
}
+2 -2
View File
@@ -36,7 +36,7 @@ import { AskTimeout } from "./AskTimeout";
import { ProgressWatch } from "./ProgressWatch";
import { DisplaySettings } from "./DisplaySettings";
import { AutoArchive } from "./AutoArchive";
import { Persona } from "./Persona";
import { AgentPresets } from "./AgentPresets";
import { Account } from "./Account";
import { Backup } from "./Backup";
import { BackupFile } from "./BackupFile";
@@ -531,7 +531,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
</>
)}
{at === "persona" && <Persona port={port} />}
{at === "persona" && <AgentPresets port={port} />}
{at === "providers" && (
<Group id="providers" title={t("模型服务")}
+2
View File
@@ -4,6 +4,7 @@ import { bytes } from "../i18n/format";
import type { AgentPort } from "../port/port";
import type { HubPort, LegacyImport } from "../port/hub";
import { LegacySkips } from "./LegacySkips";
import { NativeSessionImport } from "./NativeSessionImport";
import type { StoragePlan, StorageRoot, StorageState } from "../port/storage";
// Three questions in the order a person asks them: how much is there, where is
@@ -181,6 +182,7 @@ export function Storage({ port, hub, workspace, onRecovered }: { port: AgentPort
return (
<div className="storage">
<NativeSessionImport hub={hub} workspace={workspace} onImported={onRecovered} />
<section className="grp">
<h3 className="lbl">{t("找回旧版会话")}</h3>
<div className="item">
+2 -2
View File
@@ -98,7 +98,7 @@ export const NAV: [string, [Section, string][]][] = [
"本轮执行",
[
["session", "会话"],
["persona", "人设"],
["persona", "Agent 预设"],
["model", "模型偏好"],
["providers", "模型服务"],
["tools", "工具与权限"],
@@ -214,7 +214,7 @@ export const SETTINGS: SettingEntry[] = [
{ section: "session", anchor: "auto-archive", title: "自动归档不活跃的对话", scope: "machine", apply: "immediate", keywords: ["归档", "过期", "清理", "闲置", "天数"] },
// Composed into the system prompt once at boot as part of the cache-stable
// prefix: the save lands now, and only sessions opened afterwards read it.
{ section: "persona", anchor: "persona", title: "系统人设", scope: "machine", apply: "on-open", keywords: ["人设", "persona", "身份", "提示词", "系统提示词", "前缀", "后缀", "complete", "预设"] },
{ section: "persona", anchor: "agent-presets", title: "Agent 预设", scope: "machine", apply: "on-open", keywords: ["人设", "persona", "身份", "提示词", "系统提示词", "前缀", "后缀", "complete", "预设", "组合", "Agent"] },
{ section: "session", anchor: "session-dir", title: "会话写入位置", scope: "workspace", apply: "none", keywords: ["工作目录", "路径"] },
{ section: "model", anchor: "model", title: "按用途选择模型", scope: "machine", apply: "runtime-rebuild", keywords: ["主模型", "默认模型", "模型", "切换", "端点", "按任务指定模型", "角色分工", "子代理", "规划", "看图", "复核", "决策", "网页搜索", "web search", "system one", "typesafe", "laya", "自动命名", "思考强度", "推理", "reasoning", "档位"] },
@@ -4,12 +4,14 @@ import type { HubPort, TreeSession } from "../port/hub";
export async function saveSessionFile(hub: Pick<HubPort, "exportSession">, session: TreeSession) {
const exported = await hub.exportSession(session.path);
const records = exported.content
let nativeArchive: unknown;
try {const parsed=JSON.parse(exported.content) as {format?:string};if(parsed.format==="reasonix-native-session") nativeArchive=parsed;} catch {}
const records = nativeArchive?[]:exported.content
.split(/\r?\n/)
.filter((line) => line.trim())
.map((line) => JSON.parse(line) as unknown);
const content = JSON.stringify(
{ version: 1, title: session.title || session.name, exportedAt: new Date().toISOString(), records },
nativeArchive??{ version: 1, title: session.title || session.name, exportedAt: new Date().toISOString(), records },
null,
2,
);
+4 -2
View File
@@ -31,7 +31,7 @@ interface Deps {
// only the latest one moves focus and a failure of an older one changes nothing.
export function useOpenPane({ hub, runtimes, panePorts, active, reportsRef, runsRef, setRuntimes, setActive, setTakeover, reloadPanes, sw }: Deps) {
const placePane = useCallback(
async (req: { root?: string; sessionPath?: string }): Promise<Placed> => {
async (req: { root?: string; sessionPath?: string; agentComposition?: string }): Promise<Placed> => {
// Blank means never written to and not working: a new session mid-turn has
// no path in a stale pane list yet, and taking it over would hide its run.
const blank = runtimes.find(
@@ -41,6 +41,8 @@ export function useOpenPane({ hub, runtimes, panePorts, active, reportsRef, runs
// folder: it is the pane being asked for. Rebuilding it would cost a full
// assembly to arrive back where we started.
if (blank && !req.sessionPath && blank.root === req.root) {
const port=panePorts.get(blank.id);
if(port) {const chosen=req.agentComposition??(await port.agentPresets()).default;await port.selectAgentComposition(chosen);}
return { id: blank.id, changed: false };
}
// Same folder: the pane just rebinds, so nothing is torn down and a draft
@@ -93,7 +95,7 @@ export function useOpenPane({ hub, runtimes, panePorts, active, reportsRef, runs
const flights = useRef(new Map<string, Promise<Placed>>());
const openPane = useCallback(
async (req: { root?: string; sessionPath?: string }) => {
async (req: { root?: string; sessionPath?: string; agentComposition?: string }) => {
const wanted = req.sessionPath ?? "";
if (wanted && sw.opening(wanted)) return;
const ticket = sw.begin(wanted);
+1
View File
@@ -13,6 +13,7 @@ const ROUTES = [
// so without it a second session's traffic answers with the SPA shell.
"/runtimes", "/tree", "/rt", "/remotes", "/side",
"/appearance",
"/agent-presets", "/agent-composition",
"/events", "/history", "/status", "/balance", "/submit", "/cancel", "/approve", "/answer",
"/adjudications", "/execution-graph", "/jobs", "/tool-calls",
"/plan", "/plan-decision", "/goal", "/resume", "/models", "/tool-approval-mode", "/workspace-trust", "/preset",
+140
View File
@@ -0,0 +1,140 @@
---
owner: "@SivanCola"
backup: "@esengine"
status: active
reviewed: 2026-10-17
---
# Session Agent compositions
Studio's **Agent presets** page manages a persona together with tools, skills,
MCP servers, extensions, hooks and custom commands. A composition is independent
of the model, reasoning effort and the existing balanced/delivery role setting.
It uses Reasonix's runtime; it does not load Harness's Cordis plugins.
## Choosing and editing
Choose **Agent** beside the composer before the first prompt. Once a prompt is
accepted, the conversation keeps its binding.
Queued work, pending questions and
background jobs also prevent switching. The locked selector offers a new
conversation with another composition. Clearing or rewinding an existing
composition does not unlock it.
The built-ins are General (inherit current configuration), Code review (read
files, list directories, glob and grep), and Research (the same readers plus
web search and fetch).
Review and Research load no custom skills, commands,
MCP, extensions, hooks or subagents. They retain host controls needed to answer
and ask questions. Unavailable web services report their existing runtime
diagnostic.
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;
imports preview missing dependencies without installing or launching them.
Saving creates a definition revision. Existing conversations retain their
resolved persona, parameters and 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.
## Files and retained resources
| Item | Location |
| --- | --- |
| Personal definitions | `<state>/agent-presets/definitions/<id>.json` |
| Shared definitions | `<workspace>/.reasonix/agent-presets/definitions/<id>.json` |
| Definition revisions and explicit defaults | `<state>/agent-presets/` |
| Local authorization records and retained files | `<state>/trusted/agent-presets/` |
`<state>` is Reasonix's resolved state root, including relocation. IDs start
with `builtin:`, `user:` or `project:`. A project binding also records its
workspace. Matching display names coexist; one scope never shadows another.
Project files cannot select a default: workspace defaults are the user's
explicit choice, stored in personal state.
Definitions use `schemaVersion: 1`. Writes use atomic replacement and an
expected revision.
Exported definitions contain references rather than
credential values and never contain extension binaries. MCP supports `args`,
`url`, `env`, `headers`, `callTimeoutSeconds` and `startupTimeoutSeconds`;
extensions support `args`, `env` and `timeoutMillis`.
Parameter names are
validated. Environment/header overrides require `${NAME}` references.
Presets cannot replace an installed extension's executable declaration.
Inherited choices resolve once at binding. Retained skill and hook files and
extension code do not follow subsequent source edits.
Credential lookups remain
with the existing credential/configuration owner. Sessions use separate MCP
connections and process state, and extensions run from private copies. Existing
trust, revocation, authorization and sandbox checks still apply.
A binding is local transcript metadata and never enters provider messages.
Composition transcripts use native event-log schema 4; older readers refuse
them instead of continuing with another tool set. 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
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
MCP processes and extension sidecars use private package copies; shutdown and
failed assembly release their private directories.
The nonempty legacy user persona migrates once to a personal default preset.
The original configuration remains. `/persona` reads and edits that personal
default rather than maintaining a second mutable persona. Migration retries
reuse an already written revision and preserve conflicting personal IDs.
## Entry points
CLI: `reasonix --agent-composition builtin:review`, or
`reasonix run --agent-composition user:my-agent "Review this change"`.
`/agent` lists compositions; `/agent user:my-agent` selects one in a blank
interactive conversation. `/preset` retains its balanced/delivery meaning.
ACP exposes an independent `agent_composition` configuration option and narrows
it to the current choice after the conversation starts. Remote sessions use the
remote host's catalog, credentials and retained resources.
| HTTP method and route | Request / result |
| --- | --- |
| `GET /agent-presets` | Catalog, effective default, module IDs and diagnostics |
| `GET /agent-presets/{id}` | Definition and revision |
| `POST /agent-presets` | `{definition, expectedRevision}` → saved version |
| `DELETE /agent-presets/{id}?revision=...` | Delete the matching declaration |
| `POST /agent-presets/default` | `{id, workspace}` |
| `GET /agent-presets/{id}/export` | Portable definition JSON |
| `POST /agent-presets/import` | `{content}` → `{definition, diagnostics}` preview |
| `GET /agent-composition` | Current binding summary, including lock/unavailability |
| `POST /agent-composition` | `{id}` → selected summary |
| `POST /runtimes` | Existing create fields plus optional `agentComposition` |
| `POST /tree/sessions/export` | `{path}` → `{name, content}`; native envelope for bound sessions |
| `POST /tree/sessions/import-native` | `{workspace, content}` → imported `{path, agentComposition}` |
`/status` and runtime listings include `agentComposition`. Refusals use stable
`agent_composition.invalid`, `.not_found`, `.read_only`, `.conflict`, `.locked`
and `.unavailable` codes. The Studio catalogs translate these into Chinese and
English.
Preset names, revisions and diagnostics do not enter the cached
provider prefix. First use of a different composition can have a cold cache;
editing a preset never rewrites a running conversation's prefix.
+87
View File
@@ -0,0 +1,87 @@
---
owner: "@SivanCola"
backup: "@esengine"
status: active
reviewed: 2026-10-17
---
# 会话级 Agent 预设
Studio 的「Agent 预设」把人设、工具、技能、MCP、扩展、Hook 和自定义命令
组合在一起。模型、推理强度与均衡/交付档位继续独立设置。
运行时复用 Reasonix,不加载 Harness 的 Cordis 插件。
## 选择和修改
首次提交前,在输入框旁选择 Agent。接纳首条消息后,会话保留原绑定;
排队、待回答和后台工作期间也不能切换。锁定后可用其他组合新建会话。
清空或回退已开始的组合会话不会解除锁定。
内置通用、代码审查和资料研究三套组合。通用在绑定时继承当前配置;
代码审查只提供文件读取、目录、glob 和 grep;资料研究增加网页搜索和抓取。
后两者不装载自定义技能、命令、MCP、扩展、Hook 或子 Agent,保留回答和提问
所需的宿主控制工具。网页服务不可用时,由现有工具报告具体原因。
内置项只读,复制后才能修改。每类模块支持「绑定时继承当前配置」或
「仅使用所选项」;显式空列表表示禁用。编辑器提供本机模块目录,导入先预览
缺失依赖,不会安装依赖或启动程序。
保存产生新的定义版本,已存在的会话继续使用原先解析的人设、参数和资源。
页面分别显示定义版本和当前会话绑定版本。并发保存冲突时保留草稿并提示重载。
## 存储与兼容
| 内容 | 位置 |
| --- | --- |
| 用户声明 | `<用户状态根>/agent-presets/definitions/<id>.json` |
| 项目共享声明 | `<工作区>/.reasonix/agent-presets/definitions/<id>.json` |
| 定义版本和显式默认选择 | `<用户状态根>/agent-presets/` |
| 本机授权记录和保留文件 | `<用户状态根>/trusted/agent-presets/` |
状态根遵守存储迁移设置。身份使用 `builtin:`、`user:`、`project:` 前缀;项目
绑定同时记录工作区。同名项按身份和来源并列显示。项目文件不能设置默认值,
项目默认组合必须由用户主动选择,并保存在个人状态中。
声明使用 `schemaVersion: 1`,保存采用原子替换和预期版本校验。
导出不包含凭据值或扩展二进制。MCP 参数包括 `args`、`url`、`env`、`headers`、
`callTimeoutSeconds`、`startupTimeoutSeconds`;扩展参数包括 `args`、`env` 和
`timeoutMillis`。
参数名会校验;env/headers 覆盖值只允许 `${NAME}` 引用。
预设不能替换已安装扩展的可执行入口。
继承项只在绑定时解析一次。技能、Hook 和扩展代码使用保留副本,后续源文件
修改不会改写旧版本。凭据继续由原有凭据或配置机制提供。MCP 连接与进程状态
按会话隔离,扩展使用独立运行副本。原有信任、撤销、授权和沙箱规则继续生效。
绑定只进入本地会话元数据,不发送给 provider。组合会话使用原生事件日志
schema 4,旧程序会明确拒绝。无绑定历史仍走兼容路径,不套用当前默认组合。
无法授权或装配原绑定时,可以只读查看历史,不能继续执行。删除声明不会
删除已有会话所需的绑定版本。
原生导出使用 `reasonix-native-session` version 2 封装,包含权威日志中的绑定
和保留资源,可在「设置 → 存储」导入。导入不创建本机授权,不启动程序。
配置备份 version 3 同时包含个人预设声明及个人默认选择。
MCP 在绑定时锁定工具契约。刷新或重连后若工具目录不兼容,会阻止继续执行,
不会静默替换 schema。扩展及包内 MCP 采用私有运行副本,关闭和装配失败时
释放对应临时目录。Hook 命令中的凭据必须改用环境引用。
非空旧用户人设会幂等迁移为个人默认组合,保留四个字段且不删除旧配置。
`/persona` 适配个人默认组合,不再维护另一份可独立修改的权威人设。
迁移中断后会复用已写入版本,遇到已有个人身份时保留原声明。
## 各入口
CLI 使用 `--agent-composition builtin:review` 或 `--agent-composition user:my-agent`。
交互会话的 `/agent` 列出目录,`/agent user:my-agent` 在首次提交前选择组合。
`/preset` 仍表示均衡/交付档位。ACP 增加独立的 `agent_composition` 选项,开始
对话后只保留当前选择。远程会话使用远端目录、凭据和资源。
HTTP 路由、请求字段与稳定错误代码见 [接口说明](AGENT_COMPOSITIONS.md#entry-points)。
`/status` 和会话运行时列表返回 `agentComposition`。导入返回定义与依赖诊断,
需要用户检查后另行保存。错误代码在 Studio 中提供中英文翻译。
预设名称、版本和诊断不进入缓存前缀。切换到另一套组合首次使用时可能产生
冷缓存;编辑预设不会重写正在运行的会话前缀。
+1
View File
@@ -58,6 +58,7 @@ Checked against `studio` at `d779d89bc` on 2026-10-08.
| Turn navigator | Have differently | Scroll rail with marks in `ui/Transcript.tsx` |
| Model picker with vendor icons, search and favourites | Missing | #12354, #11403 |
| Reasoning effort picker | Have differently | Names plus one-line descriptions, only levels the model supports; do not revert to raw `high` and `max` |
| Agent composition / Agent 组合 | Have differently | Composer selector and Settings → Agent presets (`ui/AgentPresets.tsx`). 输入框选择组合,「Agent 预设」整合人设页并保留四个字段。 |
| Approval card offering "allow matching actions this session" | Unverified | Mock fixture shows two buttons only; #12020 (closed) |
| Plan and todo panel | Have | `ui/Plan.tsx` |
| Subagents panel | Have differently | `ui/Pane.tsx`, `ui/delegation.ts`; running-state gaps in #12410, #12284 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+53 -7
View File
@@ -2,6 +2,7 @@ package boot
import (
"context"
"crypto/rand"
"errors"
"fmt"
"io"
@@ -113,6 +114,16 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
b.ctx, b.opts, b.owner, b.fileWriteReceipt = bindRuntimeOwner(context.WithoutCancel(ctx), opts)
defer b.retireUnownedSidecars()
delivered := false
var constructed *control.Controller
defer func() {
if !delivered {
if constructed != nil {
constructed.ReleaseResources()
} else {
b.discardBuildResources()
}
}
}()
if b.owner.Notices != nil && b.opts.Sink != nil {
window := b.owner.Notices.Begin(b.opts.Sink)
b.opts.Sink = window
@@ -124,10 +135,14 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
if err := b.wireTools(); err != nil {
return nil, err
}
if err := b.bindCompositionMCPContracts(ctx); err != nil {
return nil, err
}
ctrl, err := b.controller()
if err != nil {
return nil, err
}
constructed = ctrl
b.tools.candidates.bind(ctrl)
b.tools.isolation.bind(ctrl)
res, err := b.freeze(ctrl)
@@ -181,6 +196,10 @@ func (b *builder) load() error {
if b.cfg, err = b.loadConfig(); err != nil {
return err
}
if err := b.prepareComposition(); err != nil {
return err
}
opts = b.opts
cfg := b.cfg
migrations.deepSeekErr = deepSeekProtocolMigrationNoticeError(handleConfigLoadWarnings(opts, cfg, b.stderr), migrations.deepSeekErr)
// [secrets] is user-global, so arming these package globals before any
@@ -248,6 +267,9 @@ func (b *builder) load() error {
b.timer.mark("shell")
slog.Debug("boot: shell tool interpreter resolved", "kind", b.shell.Kind.String(), "path", b.shell.Path, "prefer", cfg.Tools.Shell.Prefer)
b.prompt, err = buildPromptAssembly(b.ctx, opts, cfg, b.root, b.shell, b.sink, b.timer)
if err == nil {
err = b.bindCompositionSkills()
}
return err
}
@@ -331,7 +353,9 @@ func (b *builder) wireTools() error {
if cfg.Agent.CodeMode {
t.reg.Add(script.New())
}
b.wireMCP()
if err := b.wireMCP(); err != nil {
return err
}
t.browser = bindMachineTools(t.reg, cfg, root, opts.BrowserSession, opts.ComputerDriver)
b.timer.mark("mcp")
@@ -351,7 +375,10 @@ func (b *builder) wireTools() error {
WithAllowDynamicBashFallback(cfg.Permissions.AllowDynamicBash).
WithSessionAllow(opts.PermissionAllow)
t.gate = control.NewSharedHeadlessGate(t.policy, opts.HeadlessApprovalMode)
t.hooks, t.hookRunner = loadHooks(opts, b.roots, root, b.shell, b.sink)
t.hooks, t.hookRunner, err = loadHooks(opts, b.roots, root, b.shell, b.sink)
if err != nil {
return err
}
t.roles = roleWiring{cfg: cfg, roots: b.roots, resolver: b.providers.effective, extension: b.providers.extension,
proxy: b.proxy, sink: b.sink, gate: t.gate, reg: t.reg, keep: b.keep, hooks: t.hookRunner, checkTargetAccess: t.env.targetAccessCheck()}
@@ -378,7 +405,7 @@ func (b *builder) wireTools() error {
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)
t.caps = newCapabilitySurface(b.ctx, cfg, root, t.specOptions, t.host, t.reg, b.prompt.skillStore, b.model.profile, t.mcp.enabled)
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...)
t.skillRun.capRuntime = t.caps.runtime
addExtensionTools(t.reg, b.ext.mgr, b.ext.warn)
t.reg.Add(t.caps.proxy)
@@ -389,7 +416,7 @@ func (b *builder) wireTools() error {
// 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() {
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 {
@@ -411,14 +438,20 @@ func (b *builder) wireMCP() {
PackageOwners: pluginspec.PackageOwners(cfg),
OAuthHTTPClient: b.balanceClient,
}
if opts.Composition != nil {
t.specOptions.ProcessScope = rand.Text()
}
t.mcp = resolveMCPSpecs(opts, cfg, root, t.specOptions)
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)
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
@@ -428,6 +461,7 @@ func (b *builder) wireMCP() {
prev := b.cleanup
b.cleanup = func() { prev(); _ = proxy.Close() }
}
return nil
}
// controller builds the executor, the optional planner around it, and the
@@ -464,6 +498,13 @@ func (b *builder) controller() (*control.Controller, error) {
if b.opts.Observe == nil {
applyUnifiedProviderToolSurface(t.reg, b.opts.GoalTurnsUnreachable, b.opts.Ablation, pinnedMCPServers(t.mcp.alwaysLoad, t.mcpSchemaKnown))
}
if binding := b.opts.Composition; binding != nil {
if binding.Digest != "" {
t.reg.SetProviderVisibleTools(binding.ToolSurface)
} else if binding.Version.Definition.Tools.Mode == "select" {
t.reg.SetProviderVisibleTools(nil)
}
}
return ctrl, nil
}
@@ -678,6 +719,11 @@ func (b *builder) freeze(ctrl *control.Controller) (*BuildResult, error) {
}
}
ctrl.SealObserveSurface()
ctrl.ConfigureCompositions(b.roots.Home(), nil)
if err := b.finishComposition(ctrl, snap); err != nil {
ctrl.ReleaseResources()
return nil, err
}
assembly := &ReusedAssembly{
SystemPrompt: b.prompt.prompt,
Skills: b.prompt.skills,
+13
View File
@@ -0,0 +1,13 @@
package boot
func (b *builder) discardBuildResources() {
if b.cleanup != nil {
b.cleanup()
}
if b.tools.browser != nil && b.opts.BrowserSession == nil {
b.tools.browser.Release()
}
if b.tools.env.sessionTemp != nil && b.opts.SessionTemp == nil {
b.tools.env.sessionTemp.Release()
}
}
+228
View File
@@ -0,0 +1,228 @@
package boot
import (
"encoding/json"
"fmt"
"slices"
"strings"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/contract/tool"
"reasonix/internal/ext/extension"
"reasonix/internal/ext/skill"
"reasonix/internal/session/control"
"reasonix/internal/state/agentpreset"
)
func (b *builder) prepareComposition() error {
if b.opts.Composition == nil && b.opts.AgentComposition == "" {
return nil
}
if b.opts.Composition != nil {
if err := agentpreset.New(b.roots.Home(), b.root).VerifyBinding(b.opts.Composition); err != nil {
return err
}
b.opts.Composition = agentcomposition.Clone(b.opts.Composition)
} else {
store := agentpreset.New(b.roots.Home(), b.root)
userConfig, err := b.roots.LoadUserScopeForRoot(b.root)
if err != nil {
return err
}
p := userConfig.Agent.Persona.Normalize()
if err := store.MigratePersona(agentcomposition.Persona{Prefix: p.Prefix, Suffix: p.Suffix, Complete: p.Complete, IncludeRuntimeContext: p.RuntimeContextEnabled()}); err != nil {
return err
}
id := b.opts.AgentComposition
if id == "default" {
var err error
id, err = store.Default()
if err != nil {
return err
}
}
v, err := store.Get(id)
if err != nil {
return err
}
b.opts.Composition = &agentcomposition.Binding{Version: v, Resolved: agentcomposition.Clone(v.Definition), Workspace: b.root, Resources: []agentcomposition.Resource{}}
}
binding := b.opts.Composition
if binding.Workspace != "" && binding.Workspace != b.root {
return fmt.Errorf("%w: workspace changed", agentcomposition.ErrUnavailable)
}
p := binding.Resolved.Persona
b.cfg.Agent.Persona = config.PersonaConfig{Prefix: p.Prefix, Suffix: p.Suffix, Complete: p.Complete, IncludeRuntimeContext: &p.IncludeRuntimeContext}
// Independent MCP connections keep mutable server state out of sibling sessions.
b.opts.SharedHost = nil
return b.prepareCompositionMCP()
}
func compositionAdmits(d agentcomposition.Definition) func(tool.Tool) bool {
return func(t tool.Tool) bool {
if m, ok := t.(tool.MCPMetadata); ok {
return member(d.MCP, m.MCPServerName())
}
if e, ok := t.(interface{ ExtensionPackageName() string }); ok {
return member(d.Extensions, e.ExtensionPackageName())
}
if d.Tools.Mode == agentcomposition.Inherit || tool.ReachOf(t) == tool.ReachHostControl {
return true
}
if member(d.Tools, t.Name()) {
return true
}
return false
}
}
func member(s agentcomposition.Selection, id string) bool {
if s.Mode == agentcomposition.Inherit {
return true
}
return slices.ContainsFunc(s.Members, func(m agentcomposition.Module) bool { return m.ID == id })
}
func resource[T any](binding *agentcomposition.Binding, kind string) ([]T, error) {
out := []T{}
for _, r := range binding.Resources {
if r.Kind != kind {
continue
}
var v T
if err := json.Unmarshal(r.Data, &v); err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
}
func keepResource(binding *agentcomposition.Binding, kind, id string, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
binding.Resources = append(binding.Resources, agentcomposition.Resource{Kind: kind, ID: id, Data: b})
total := 0
for _, r := range binding.Resources {
total += len(r.Data)
}
if total > 128<<20 {
return agentcomposition.Unavailable(id, "Retained resources exceed the session limit.")
}
return nil
}
func (b *builder) bindCompositionSkills() error {
binding := b.opts.Composition
if binding == nil {
return nil
}
var skills []skill.Skill
if binding.Digest != "" {
var err error
skills, err = resource[skill.Skill](binding, "skill")
if err != nil {
return err
}
if err := b.restoreSkills(skills); err != nil {
return err
}
if binding.SystemPrompt != "" {
b.prompt.prompt = binding.SystemPrompt
}
} else {
skills = []skill.Skill{}
for _, sk := range b.prompt.skillStore.CompositionSkills() {
if !member(binding.Resolved.Skills, sk.SlashName()) || sk.Plugin != "" && !member(binding.Resolved.Extensions, sk.Plugin) {
continue
}
var err error
sk, err = b.retainSkill(sk)
if err != nil {
return err
}
if err := keepResource(binding, "skill", sk.SlashName(), sk); err != nil {
return err
}
skills = append(skills, sk)
}
if err := verifySelected(binding.Resolved.Skills, skills, func(s skill.Skill) string { return s.SlashName() }); err != nil {
return err
}
binding.Resolved.Skills = selectionOf(skills, func(s skill.Skill) string { return s.SlashName() })
}
b.prompt.skillStore = b.prompt.skillStore.Freeze(skills)
if err := b.prompt.skillStore.ValidateSnapshot(); err != nil {
return fmt.Errorf("%w: %w", agentcomposition.ErrUnavailable, err)
}
b.prompt.skills = b.prompt.skillStore.List()
return nil
}
func selectionOf[T any](items []T, id func(T) string) agentcomposition.Selection {
s := agentcomposition.Selection{Mode: agentcomposition.Select, Members: []agentcomposition.Module{}}
for _, item := range items {
s.Members = append(s.Members, agentcomposition.Module{ID: id(item)})
}
return s
}
func verifySelected[T any](s agentcomposition.Selection, items []T, id func(T) string) error {
if s.Mode == agentcomposition.Inherit {
return nil
}
for _, m := range s.Members {
if !slices.ContainsFunc(items, func(v T) bool { return id(v) == m.ID }) {
return agentcomposition.Unavailable(m.ID, "Required module is unavailable or was revoked.")
}
}
return nil
}
func (b *builder) finishComposition(ctrl *control.Controller, snap *extension.RuntimeSnapshot) error {
binding := b.opts.Composition
if binding == nil {
return nil
}
if snap == nil {
return fmt.Errorf("%w: runtime snapshot missing", agentcomposition.ErrUnavailable)
}
if diagnostics := snap.Diagnostics(); len(diagnostics) > 0 {
return agentcomposition.Unavailable("assembly", strings.Join(diagnostics, "; "))
}
if binding.Digest != "" {
if binding.ToolSchemaDigest != agentcomposition.Digest(b.tools.reg.Schemas()) {
return fmt.Errorf("%w: tool contract changed", agentcomposition.ErrUnavailable)
}
} else {
if err := verifySelected(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 })
binding.SystemPrompt = snap.SystemPrompt()
binding.ToolSchemaDigest = agentcomposition.Digest(b.tools.reg.Schemas())
binding.ToolSurface = []string{}
for _, schema := range b.tools.reg.Schemas() {
binding.ToolSurface = append(binding.ToolSurface, schema.Name)
}
binding.Seal()
if err := agentpreset.New(b.roots.Home(), b.root).RetainBinding(binding); err != nil {
return err
}
}
ctrl.BindComposition(binding)
ctrl.ConfigureCompositionValidation(func() error { return b.validateCompositionAvailability() })
b.tools.reg.Restrict(func(t tool.Tool) bool {
if !compositionAdmits(binding.Resolved)(t) {
return false
}
return b.opts.Observe == nil || observeAdmits(b.opts.Observe.Tools)(t)
})
if b.tools.caps != nil && b.tools.caps.runtime != nil {
b.tools.caps.runtime.FreezeComposition()
}
return nil
}
@@ -0,0 +1,60 @@
package boot
import (
"encoding/json"
"os"
"path/filepath"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/ext/skill"
"reasonix/internal/state/agentpreset"
)
func (b *builder) retainSkill(sk skill.Skill) (skill.Skill, error) {
if sk.Scope == skill.ScopeBuiltin || sk.Path == "" || sk.Path == "(builtin)" {
return sk.Complete(), nil
}
var asset agentpreset.DirectorySnapshot
var err error
if filepath.Base(sk.Path) == skill.SkillFile {
asset, err = agentpreset.CaptureDirectory(filepath.Dir(sk.Path))
} else {
var data []byte
data, err = os.ReadFile(sk.Path)
asset.Files = []agentpreset.AssetFile{{Path: filepath.Base(sk.Path), Data: data, Mode: 0o600}}
}
if err != nil {
return sk, err
}
if err := keepResource(b.opts.Composition, "skill-assets", sk.SlashName(), asset); err != nil {
return sk, err
}
root, err := agentpreset.New(b.roots.Home(), b.root).Materialize(asset)
if err != nil {
return sk, err
}
sk.Path = filepath.Join(root, filepath.Base(sk.Path))
return sk.Complete(), nil
}
func (b *builder) restoreSkills(skills []skill.Skill) error {
for i := range skills {
for _, r := range b.opts.Composition.Resources {
if r.Kind != "skill-assets" || r.ID != skills[i].SlashName() {
continue
}
var asset agentpreset.DirectorySnapshot
if err := jsonResource(r, &asset); err != nil {
return err
}
root, err := agentpreset.New(b.roots.Home(), b.root).Materialize(asset)
if err != nil {
return err
}
skills[i].Path = filepath.Join(root, filepath.Base(skills[i].Path))
}
}
return nil
}
func jsonResource(r agentcomposition.Resource, v any) error { return json.Unmarshal(r.Data, v) }
@@ -0,0 +1,29 @@
package boot
import (
"fmt"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/ext/pluginpkg"
)
func (b *builder) validateCompositionAvailability() error {
if err := b.prompt.skillStore.ValidateSnapshot(); err != nil {
return fmt.Errorf("%w: %w", agentcomposition.ErrUnavailable, err)
}
if err := b.tools.host.CompositionContractError(); err != nil {
return fmt.Errorf("%w: %w", agentcomposition.ErrUnavailable, err)
}
for _, entry := range b.cfg.Plugins {
decision, _, err := config.DefaultActivationStore().ServerDecision(entry, b.root)
if err != nil {
return fmt.Errorf("%w: %w", agentcomposition.ErrUnavailable, err)
}
if decision == config.ActivationDisabled {
return agentcomposition.Unavailable(entry.Name, "MCP authorization was revoked.")
}
}
installed, _ := pluginpkg.LoadInstalled(b.roots.Home())
return verifySelected(b.opts.Composition.Resolved.Extensions, installed, func(p pluginpkg.InstalledPackage) string { return p.Installed.Name })
}
@@ -0,0 +1,131 @@
package boot
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/ext/plugin"
)
type compositionBoundaryProvider struct{ requests []provider.Request }
func (p *compositionBoundaryProvider) Name() string { return "composition-boundary" }
func (p *compositionBoundaryProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.requests = append(p.requests, req)
calls := []provider.ToolCall{
{ID: "shell", Name: "bash", Arguments: `{"command":"touch forbidden"}`},
{ID: "proxy", Name: "use_capability", Arguments: `{"action":"call","id":"tool:bash","arguments":{"command":"touch forbidden"}}`},
{ID: "child", Name: "task", Arguments: `{"prompt":"touch forbidden"}`},
}
ch := make(chan provider.Chunk, 2)
if len(p.requests) <= len(calls) {
ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &calls[len(p.requests)-1]}
} else {
ch <- provider.Chunk{Type: provider.ChunkText, Text: "review complete"}
}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
func compositionTestWorkspace(t *testing.T, kind string) (string, string) {
t.Helper()
home := statedBootHome(t)
root := robustTempDir(t)
writeFile(t, root, "reasonix.toml", `default_model = "test-model"
[[providers]]
name = "test-model"
kind = "`+kind+`"
base_url = "https://example.invalid"
model = "x"
`)
approveWorkspaceAt(t, home, root)
return home, root
}
func TestCompositionDeniesProxyLateRegistrationAndDelegationAtProviderBoundary(t *testing.T) {
p := &compositionBoundaryProvider{}
provider.Register("composition-boundary", func(provider.Config) (provider.Provider, error) { return p, nil })
home, root := compositionTestWorkspace(t, "composition-boundary")
res, err := BuildRuntime(context.Background(), Options{Home: home, WorkspaceRoot: root, Model: "test-model", AgentComposition: "builtin:review", resolvedShell: pinnedEffectShell()})
if err != nil {
t.Fatal(err)
}
defer res.Controller.Close()
res.Controller.EnsureSessionPath()
for _, candidate := range tool.Builtins() {
if candidate.Name() == "bash" {
res.Assembly.Registry.Add(candidate)
}
}
if _, ok := res.Assembly.Registry.Get("bash"); ok {
t.Fatal("late registration widened composition")
}
if err := res.Controller.RunTurn(context.Background(), "Review this workspace"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, "forbidden")); !os.IsNotExist(err) {
t.Fatal("forbidden tool executed")
}
if len(p.requests) < 2 {
t.Fatal("test did not reach rejected-call continuation")
}
var schema []byte
for _, req := range p.requests {
for _, s := range req.Tools {
if s.Name == "bash" || s.Name == "task" || s.Name == "use_capability" || s.Name == "write_file" {
t.Fatalf("forbidden schema %s", s.Name)
}
}
encoded, _ := json.Marshal(req.Tools)
if schema != nil && string(schema) != string(encoded) {
t.Fatal("same binding changed provider tool bytes")
}
schema = encoded
for _, m := range req.Messages {
if m.AgentComposition != nil {
t.Fatal("local binding leaked into provider request")
}
}
if !strings.Contains(req.Messages[0].Content, "rigorous code reviewer") {
t.Fatal("resolved persona missing at provider boundary")
}
}
}
func TestCompositionReviewDoesNotStartHostSuppliedMCP(t *testing.T) {
home, root := compositionTestWorkspace(t, "openai")
res, err := BuildRuntime(context.Background(), Options{Home: home, WorkspaceRoot: root, Model: "test-model", AgentComposition: "builtin:review", resolvedShell: pinnedEffectShell(), ExtraPlugins: []plugin.Spec{{Name: "excluded", Type: "stdio", Command: "/composition-must-not-start"}}})
if err != nil {
t.Fatal(err)
}
defer res.Controller.Close()
if len(res.Controller.CompositionBinding().Resolved.MCP.Members) != 0 {
t.Fatal("host MCP escaped selection")
}
}
func TestCompositionGeneralKeepsLegacyProviderPrefix(t *testing.T) {
home, root := compositionTestWorkspace(t, "openai")
opts := Options{Home: home, WorkspaceRoot: root, Model: "test-model", resolvedShell: pinnedEffectShell()}
legacy, err := BuildRuntime(context.Background(), opts)
if err != nil {
t.Fatal(err)
}
defer legacy.Controller.Close()
opts.AgentComposition = "builtin:general"
composed, err := BuildRuntime(context.Background(), opts)
if err != nil {
t.Fatal(err)
}
defer composed.Controller.Close()
if legacy.Snapshot.CacheHash() != composed.Snapshot.CacheHash() {
t.Fatal("default composition changed the cached provider prefix")
}
}
@@ -0,0 +1,187 @@
package boot
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/ext/pluginpkg"
"reasonix/internal/state/agentpreset"
)
type retainedExtension struct {
Package pluginpkg.InstalledPackage
Assets agentpreset.DirectorySnapshot
RuntimeSnapshot *agentpreset.MCPSnapshot
}
func (b *builder) retainExtension(p pluginpkg.InstalledPackage) error {
assets, err := agentpreset.CaptureDirectory(p.Package.Root)
if err != nil {
return err
}
files := assets.Files[:0]
for _, f := range assets.Files {
if f.Path == pluginpkg.NativeManifest || f.Path == pluginpkg.CodexManifest || f.Path == pluginpkg.ClaudeManifest {
continue
}
if f.Path == ".mcp.json" || f.Path == "hooks/hooks.json" {
f.Data, _, err = pluginpkg.StripCredentials(f.Data)
if err != nil {
return err
}
}
files = append(files, f)
}
assets.Files = files
var runtimeSnapshot *agentpreset.MCPSnapshot
if p.Package.Manifest.Runtime != nil {
rt := p.Package.Manifest.Runtime
snapshot := agentpreset.CaptureMCP(config.PluginEntry{Name: p.Installed.Name, Command: rt.Command, Args: rt.Args, Env: rt.Env})
runtimeSnapshot = &snapshot
}
p = agentcomposition.Clone(p)
p.Installed.Source, p.Warnings = "", nil
// Environment and header values remain owned by the live credential/config layer.
raw, err := json.Marshal(p)
if err != nil {
return err
}
raw, _, err = pluginpkg.StripCredentials(raw)
if err != nil {
return err
}
var tree any
if err := json.Unmarshal(raw, &tree); err != nil {
return err
}
stripCompositionCredentials(tree)
raw, err = json.Marshal(tree)
if err != nil {
return err
}
if err := json.Unmarshal(raw, &p); err != nil {
return err
}
if runtimeSnapshot != nil {
p.Package.Manifest.Runtime.Args = runtimeSnapshot.Entry.Args
}
return keepResource(b.opts.Composition, "extension", p.Installed.Name, retainedExtension{Package: p, Assets: assets, RuntimeSnapshot: runtimeSnapshot})
}
func stripCompositionCredentials(value any) {
switch v := value.(type) {
case map[string]any:
for key, child := range v {
if strings.EqualFold(key, "env") || strings.EqualFold(key, "headers") {
delete(v, key)
} else {
stripCompositionCredentials(child)
}
}
case []any:
for _, child := range v {
stripCompositionCredentials(child)
}
}
}
func restoreExtension(home string, live pluginpkg.InstalledPackage, binding *agentcomposition.Binding) (pluginpkg.InstalledPackage, error) {
old, err := resource[retainedExtension](binding, "extension")
if err != nil {
return live, err
}
for _, saved := range old {
p := saved.Package
if p.Installed.Name != live.Installed.Name {
continue
}
if p.Package.Manifest.Runtime != nil {
if live.Package.Manifest.Runtime == nil || p.Package.Manifest.Runtime.Command != live.Package.Manifest.Runtime.Command {
return live, fmt.Errorf("%w: extension entry authorization changed", agentcomposition.ErrUnavailable)
}
if saved.RuntimeSnapshot == nil {
return live, agentcomposition.ErrUnavailable
}
rt := live.Package.Manifest.Runtime
resolved, err := saved.RuntimeSnapshot.Resolve(config.PluginEntry{Name: live.Installed.Name, Command: rt.Command, Args: rt.Args, Env: rt.Env})
if err != nil {
return live, err
}
p.Package.Manifest.Runtime.Args, p.Package.Manifest.Runtime.Env = resolved.Args, resolved.Env
}
root, err := agentpreset.New(home, binding.Workspace).Materialize(saved.Assets)
if err != nil {
return live, err
}
if rt := p.Package.Manifest.Runtime; rt != nil {
rt.Command = rebaseCompositionPath(rt.Command, p.Package.Root, root)
for i, arg := range rt.Args {
rt.Args[i] = rebaseCompositionPath(arg, p.Package.Root, root)
}
}
p.Package.Root = filepath.Clean(root)
return p, nil
}
return live, fmt.Errorf("%w: retained extension %s missing", agentcomposition.ErrUnavailable, live.Installed.Name)
}
func compositionPackages(home string, packages []pluginpkg.InstalledPackage, binding *agentcomposition.Binding) ([]pluginpkg.InstalledPackage, error) {
if binding == nil {
return packages, nil
}
installed, warnings := pluginpkg.LoadInstalled(home)
if err := verifySelected(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, "; "))
}
out := []pluginpkg.InstalledPackage{}
for _, p := range installed {
if !member(binding.Resolved.Extensions, p.Installed.Name) {
continue
}
var err error
p, err = restoreExtension(home, p, binding)
if err != nil {
return nil, err
}
for _, m := range binding.Resolved.Extensions.Members {
if m.ID != p.Installed.Name {
continue
}
params, err := parameters(m.Parameters)
if err != nil {
return nil, err
}
if params.URL != nil || params.Headers != nil || params.CallTimeoutSeconds != nil || params.StartupTimeoutSeconds != nil {
return nil, fmt.Errorf("%w: unsupported extension parameter", agentcomposition.ErrInvalid)
}
if rt := p.Package.Manifest.Runtime; rt != nil {
copy := *rt
copy.Required = true
if params.Args != nil {
copy.Args = *params.Args
}
if params.Env != nil {
copy.Env = map[string]string{}
for key, value := range params.Env {
copy.Env[key] = os.ExpandEnv(value)
}
}
if params.TimeoutMillis != nil {
copy.TimeoutMillis = *params.TimeoutMillis
}
p.Package.Manifest.Runtime = &copy
} else if len(m.Parameters) > 0 && string(m.Parameters) != "{}" {
return nil, fmt.Errorf("%w: extension has no configurable runtime", agentcomposition.ErrInvalid)
}
}
if p.Package.Manifest.Runtime != nil {
out = append(out, p)
}
}
return out, nil
}
@@ -0,0 +1,38 @@
package boot
import (
"context"
"encoding/json"
"fmt"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/contract/tool"
"reasonix/internal/runtime/agent"
"reasonix/internal/session/control"
"reasonix/internal/state/sessionstore"
)
// BuildCompositionHistory opens history without discovering or starting any module.
func BuildCompositionHistory(opts Options, cause error) *BuildResult {
reg := tool.NewRegistry()
prompt := ""
if opts.Composition != nil {
prompt = opts.Composition.SystemPrompt
}
sess := sessionstore.NewSession(prompt)
sess.BindComposition(opts.Composition)
exec := agent.New(nil, reg, sess, agent.Options{CheckTargetAccess: denyCompositionHistoryAccess}, opts.Sink)
ctrl := control.New(control.Options{Executor: exec, Runner: exec, Registry: reg, Sink: opts.Sink, WorkspaceRoot: opts.WorkspaceRoot, SessionDir: opts.SessionDir, ModelRef: opts.Model})
home := opts.Home
if home == "" {
home = config.ReasonixHomeDir()
}
ctrl.ConfigureCompositions(home, nil)
ctrl.BlockCompositionExecution(fmt.Errorf("%w: %w", agentcomposition.ErrUnavailable, cause))
return &BuildResult{Controller: ctrl}
}
func denyCompositionHistoryAccess(context.Context, tool.Tool, json.RawMessage) error {
return agentcomposition.ErrUnavailable
}
+167
View File
@@ -0,0 +1,167 @@
package boot
import (
"encoding/json"
"fmt"
"path/filepath"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/ext/hook"
"reasonix/internal/ext/pluginpkg"
"reasonix/internal/state/agentpreset"
)
type retainedHook struct {
Snapshot hook.Snapshot
Assets agentpreset.DirectorySnapshot
Root string
EnvKeys []string
Plugin string
}
func (b *builder) retainHook(id string, h hook.ResolvedHook) error {
launch, err := json.Marshal(map[string]any{"command": h.Command, "args": h.Argv})
if err != nil {
return err
}
sanitized, _, err := pluginpkg.StripCredentials(launch)
if err != nil {
return err
}
var original, redacted any
_ = json.Unmarshal(launch, &original)
_ = json.Unmarshal(sanitized, &redacted)
if agentcomposition.Digest(original) != agentcomposition.Digest(redacted) {
return agentcomposition.Unavailable(id, "Hook credentials must use environment references.")
}
assets, paths, err := agentpreset.CaptureFiles(h.ResourcePaths(b.root))
plugin := h.Env["REASONIX_PLUGIN_NAME"]
if h.Scope == hook.ScopePlugin {
assets, paths, err = b.compositionHookPackage(h, plugin)
}
if err != nil {
return err
}
root, err := agentpreset.New(b.roots.Home(), b.root).Materialize(assets)
if err != nil {
return err
}
for path, rel := range paths {
paths[path] = filepath.Join(root, rel)
}
h, err = h.RewriteResourcePaths(b.root, paths)
if err != nil {
return err
}
if plugin != "" {
from := h.Env["REASONIX_PLUGIN_ROOT"]
h.Cwd = rebaseCompositionPath(h.Cwd, from, root)
for key, value := range h.Env {
h.Env[key] = rebaseCompositionPath(value, from, root)
}
}
saved := retainedHook{Snapshot: h.Snapshot(), Assets: assets, Root: root, Plugin: plugin}
saved.Snapshot.Hook.Env, saved.EnvKeys = agentpreset.CaptureEnvironment(h.Env)
return keepResource(b.opts.Composition, "hook", id, saved)
}
func (b *builder) compositionHookPackage(h hook.ResolvedHook, name string) (agentpreset.DirectorySnapshot, map[string]string, error) {
packages, err := resource[retainedExtension](b.opts.Composition, "extension")
if err != nil {
return agentpreset.DirectorySnapshot{}, nil, err
}
for _, p := range packages {
if p.Package.Installed.Name != name {
continue
}
paths := map[string]string{}
for _, file := range p.Assets.Files {
paths[filepath.Join(h.Env["REASONIX_PLUGIN_ROOT"], filepath.FromSlash(file.Path))] = filepath.FromSlash(file.Path)
}
return p.Assets, paths, nil
}
return agentpreset.DirectorySnapshot{}, nil, fmt.Errorf("%w: hook package missing", agentcomposition.ErrUnavailable)
}
func compositionHookSet(opts Options, home, workspace string) ([]hook.ResolvedHook, error) {
store := agentpreset.New(home, workspace)
live := hook.Load(hook.LoadOptions{ProjectRoot: workspace, ReasonixHomeDir: home})
byID := map[string]hook.ResolvedHook{}
for i, h := range live {
byID[fmt.Sprintf("%s:%s:%d", h.Scope, h.Event, i)] = h
}
out := []hook.ResolvedHook{}
for _, r := range opts.Composition.Resources {
if r.Kind != "hook" {
continue
}
var saved retainedHook
if err := jsonResource(r, &saved); err != nil {
return nil, err
}
h, err := restoreCompositionHook(store, saved, workspace, byID[r.ID])
if err != nil {
return nil, err
}
out = append(out, h)
}
return out, nil
}
func restoreCompositionHook(store *agentpreset.Store, saved retainedHook, workspace string, live hook.ResolvedHook) (hook.ResolvedHook, error) {
check := func() error {
if _, err := store.Materialize(saved.Assets); err != nil {
return err
}
if p := saved.Snapshot.Approval; p != nil {
ok, err := config.NewProjectProgramStore(store.Home).Approved(workspace, *p)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("%w: hook approval revoked", agentcomposition.ErrUnavailable)
}
}
if saved.Plugin != "" {
installed, _ := pluginpkg.LoadInstalled(store.Home)
found := false
for _, p := range installed {
if p.Installed.Name == saved.Plugin {
found = true
}
}
if !found {
return fmt.Errorf("%w: hook package disabled", agentcomposition.ErrUnavailable)
}
}
return nil
}
if err := check(); err != nil {
return hook.ResolvedHook{}, err
}
root, err := store.Materialize(saved.Assets)
if err != nil {
return hook.ResolvedHook{}, err
}
paths := map[string]string{}
for _, f := range saved.Assets.Files {
paths[filepath.Join(saved.Root, filepath.FromSlash(f.Path))] = filepath.Join(root, filepath.FromSlash(f.Path))
}
h, err := saved.Snapshot.Restore().RewriteResourcePaths(workspace, paths)
if err != nil {
return h, err
}
if len(saved.EnvKeys) > 0 && h.Source != live.Source {
return h, agentcomposition.ErrUnavailable
}
h.Env, err = agentpreset.ResolveEnvironment(h.Env, saved.EnvKeys, live.Env)
if err != nil {
return h, err
}
h.Cwd = rebaseCompositionPath(h.Cwd, saved.Root, root)
for key, value := range h.Env {
h.Env[key] = rebaseCompositionPath(value, saved.Root, root)
}
return h.WithRetainedCheck(check), nil
}
+161
View File
@@ -0,0 +1,161 @@
package boot
import (
"encoding/json"
"fmt"
"time"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/ext/plugin"
"reasonix/internal/state/agentpreset"
)
func parameters(raw json.RawMessage) (agentcomposition.Parameters, error) {
return agentcomposition.ParseParameters(raw)
}
func (b *builder) prepareCompositionMCP() error {
binding := b.opts.Composition
if err := b.prepareCompositionModules(); err != nil {
return err
}
available := b.cfg.EnabledPlugins(b.root, config.DefaultActivationStore())
for i := range available {
var err error
available[i], err = b.rebaseCompositionMCP(available[i])
if err != nil && member(binding.Resolved.MCP, available[i].Name) {
return err
}
}
for _, spec := range b.opts.ExtraPlugins {
for _, entry := range available {
if entry.Name == spec.Name {
return fmt.Errorf("%w: duplicate MCP identity %s", agentcomposition.ErrInvalid, spec.Name)
}
}
available = append(available, compositionMCPEntry(spec))
}
var selected []config.PluginEntry
if binding.Digest != "" {
var err error
var snapshots []agentpreset.MCPSnapshot
snapshots, err = resource[agentpreset.MCPSnapshot](binding, "mcp")
if err != nil {
return err
}
for _, saved := range snapshots {
found := false
for _, live := range available {
if live.Name != saved.Entry.Name {
continue
}
entry, err := saved.Resolve(live)
if err != nil {
return err
}
selected = append(selected, entry)
found = true
}
if !found {
return fmt.Errorf("%w: MCP %s", agentcomposition.ErrUnavailable, saved.Entry.Name)
}
}
} else {
selected = []config.PluginEntry{}
for _, entry := range available {
if !member(binding.Resolved.MCP, entry.Name) {
continue
}
selected = append(selected, entry)
}
if err := verifySelected(binding.Resolved.MCP, selected, func(p config.PluginEntry) string { return p.Name }); err != nil {
return err
}
}
for i := range selected {
for _, m := range binding.Resolved.MCP.Members {
if m.ID != selected[i].Name {
continue
}
var err error
selected[i], err = applyCompositionMCPParameters(selected[i], m.Parameters)
if err != nil {
return err
}
}
if binding.Digest == "" {
copy := selected[i]
snapshot := agentpreset.CaptureMCP(copy)
for _, original := range b.cfg.EnabledPlugins(b.root, config.DefaultActivationStore()) {
if original.Name == copy.Name {
snapshot.AuthorizedCommand = original.Command
}
}
if err := keepResource(binding, "mcp", copy.Name, snapshot); err != nil {
return err
}
}
}
if binding.Resolved.MCP.Mode == agentcomposition.Inherit {
binding.Resolved.MCP = selectionOf(selected, func(p config.PluginEntry) string { return p.Name })
}
b.cfg.Plugins, b.opts.ExtraPlugins = splitCompositionMCP(selected, b.opts.ExtraPlugins, b.root)
if _, err := buildRuntimeGraph(b.roots.Home(), nil, binding); err != nil {
return agentcomposition.Unavailable("extensions", err.Error())
}
return nil
}
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
}
func compositionMCPEntry(spec plugin.Spec) config.PluginEntry {
return config.PluginEntry{Name: spec.Name, Type: spec.Type, Command: spec.Command, Args: spec.Args, URL: spec.URL, Env: spec.Env, Headers: spec.Headers, DisabledTools: spec.DisabledTools, CallTimeoutSeconds: int(spec.CallTimeout / time.Second), StartupTimeoutSeconds: int(spec.StartupTimeout / time.Second)}
}
func splitCompositionMCP(selected []config.PluginEntry, available []plugin.Spec, root string) ([]config.PluginEntry, []plugin.Spec) {
configured := []config.PluginEntry{}
extra := []plugin.Spec{}
for _, entry := range selected {
found := false
for _, spec := range available {
if spec.Name != entry.Name {
continue
}
found = true
entry = entry.ExpandedPluginForRoot(root)
spec.Args, spec.URL, spec.Env, spec.Headers = entry.Args, entry.URL, entry.Env, entry.Headers
spec.CallTimeout = time.Duration(entry.CallTimeoutSeconds) * time.Second
spec.StartupTimeout = time.Duration(entry.StartupTimeoutSeconds) * time.Second
extra = append(extra, spec)
}
if !found {
configured = append(configured, entry)
}
}
return configured, extra
}
@@ -0,0 +1,49 @@
package boot
import (
"context"
"fmt"
"time"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/ext/plugin"
)
func (b *builder) bindCompositionMCPContracts(ctx context.Context) error {
if b.opts.Composition == nil {
return nil
}
binding := b.opts.Composition
for _, spec := range append(append([]plugin.Spec{}, b.tools.mcp.configured...), b.tools.mcp.extra...) {
call, cancel := context.WithTimeout(ctx, 30*time.Second)
tools, err := b.tools.host.EnsureConnectedWithLifecycle(b.ctx, call, spec, 0)
cancel()
if err != nil {
return fmt.Errorf("%w: MCP %s: %w", agentcomposition.ErrUnavailable, spec.Name, err)
}
addServerTools(b.tools.reg, b.tools.host, spec, tools)
expected := ""
if binding.Digest != "" {
for _, r := range binding.Resources {
if r.Kind == "mcp-contract" && r.ID == spec.Name {
if err := jsonResource(r, &expected); err != nil {
return err
}
}
}
if expected == "" {
return fmt.Errorf("%w: MCP contract missing", agentcomposition.ErrUnavailable)
}
}
hash, err := b.tools.host.PinCompositionTools(spec.Name, expected)
if err != nil {
return fmt.Errorf("%w: %w", agentcomposition.ErrUnavailable, err)
}
if binding.Digest == "" {
if err := keepResource(binding, "mcp-contract", spec.Name, hash); err != nil {
return err
}
}
}
return nil
}
@@ -0,0 +1,62 @@
package boot
import (
"fmt"
"os"
"path/filepath"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/ext/plugin"
"reasonix/internal/ext/pluginspec"
"reasonix/internal/state/agentpreset"
)
func (b *builder) isolateCompositionMCPPackages() error {
if b.opts.Composition == nil {
return nil
}
packages, err := resource[retainedExtension](b.opts.Composition, "extension")
if err != nil {
return err
}
b.tools.specOptions.SessionPackageRoots = map[string]pluginspec.SessionPackageRoot{}
for _, specs := range [][]plugin.Spec{b.tools.mcp.configured, b.tools.mcp.extra} {
for i := range specs {
spec := &specs[i]
if spec.Package == "" || spec.ProcessStateDir == "" || spec.Command == "" {
continue
}
found := false
for _, p := range packages {
if p.Package.Installed.Name != spec.Package {
continue
}
retained, err := agentpreset.New(b.roots.Home(), b.root).Materialize(p.Assets)
if err != nil {
return err
}
processRoot, err := plugin.CompositionProcessRoot(*spec)
if err != nil {
return err
}
previous := b.cleanup
b.cleanup = func() {
previous()
_ = os.RemoveAll(processRoot)
}
private := filepath.Join(processRoot, "package")
if err := os.CopyFS(private, os.DirFS(retained)); err != nil {
return err
}
b.tools.specOptions.SessionPackageRoots[spec.Name] = pluginspec.SessionPackageRoot{Retained: retained, Private: private}
pluginspec.ApplySessionPackageRoot(spec, b.tools.specOptions.SessionPackageRoots)
found = true
break
}
if !found {
return fmt.Errorf("%w: MCP package resource missing", agentcomposition.ErrUnavailable)
}
}
}
return nil
}
@@ -0,0 +1,88 @@
package boot
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/ext/plugin"
"reasonix/internal/state/agentpreset"
)
func TestCompositionSessionsKeepIndependentMCPParametersAndConnections(t *testing.T) {
var mu sync.Mutex
nextID := 0
calls := map[string]int{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID *int `json:"id"`
Method string `json:"method"`
}
if json.NewDecoder(r.Body).Decode(&request) != nil || request.ID == nil {
w.WriteHeader(http.StatusAccepted)
return
}
var result any
switch request.Method {
case "initialize":
mu.Lock()
nextID++
id := fmt.Sprint(nextID)
mu.Unlock()
w.Header().Set("Mcp-Session-Id", id)
result = map[string]any{"protocolVersion": "2024-11-05", "serverInfo": map[string]any{"name": "docs", "version": "1"}, "capabilities": map[string]any{"tools": map[string]any{}}}
case "tools/list":
result = map[string]any{"tools": []any{map[string]any{"name": "echo", "description": "Echo session parameters", "inputSchema": map[string]any{"type": "object"}, "annotations": map[string]any{"readOnlyHint": true}}}}
case "tools/call":
id := r.Header.Get("Mcp-Session-Id")
mu.Lock()
calls[id]++
count := calls[id]
mu.Unlock()
result = map[string]any{"content": []any{map[string]any{"type": "text", "text": fmt.Sprintf("%s:%s:%d", r.URL.Path, r.Header.Get("X-Flavor"), count)}}}
default:
result = map[string]any{}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *request.ID, "result": result})
}))
defer server.Close()
home, root := compositionTestWorkspace(t, "openai")
t.Setenv("COMPOSITION_LEFT", "left")
t.Setenv("COMPOSITION_RIGHT", "right")
store := agentpreset.New(home, root)
results := []*BuildResult{}
for _, side := range []string{"left", "right"} {
d := agentcomposition.Builtins()[0]
d.ID = "user:" + side
params, _ := json.Marshal(map[string]any{"url": server.URL + "/" + side, "headers": map[string]string{"X-Flavor": "${COMPOSITION_" + strings.ToUpper(side) + "}"}})
d.MCP = agentcomposition.Selection{Mode: agentcomposition.Select, Members: []agentcomposition.Module{{ID: "docs", Parameters: params}}}
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}}})
if err != nil {
t.Fatal(err)
}
defer built.Controller.Close()
results = append(results, built)
}
for _, step := range []struct {
index, count int
side string
}{{0, 1, "left"}, {1, 1, "right"}, {0, 2, "left"}} {
proxy, ok := results[step.index].Assembly.Registry.Get("use_capability")
if !ok {
t.Fatal("proxy missing")
}
out, err := proxy.Execute(t.Context(), json.RawMessage(`{"action":"call","capability_id":"mcp-tool:docs/echo","arguments":{}}`))
if err != nil || !strings.Contains(out, fmt.Sprintf("/%s:%s:%d", step.side, step.side, step.count)) {
t.Fatalf("session parameters leaked: %s %v", out, err)
}
}
}
@@ -0,0 +1,111 @@
package boot
import (
"fmt"
"path/filepath"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/ext/command"
"reasonix/internal/ext/hook"
"reasonix/internal/ext/pluginpkg"
)
func (b *builder) prepareCompositionModules() error {
binding := b.opts.Composition
installed, _ := pluginpkg.LoadInstalled(b.roots.Home())
selected := []pluginpkg.InstalledPackage{}
for _, pkg := range installed {
if member(binding.Resolved.Extensions, pkg.Installed.Name) {
selected = append(selected, pkg)
}
}
if err := verifySelected(binding.Resolved.Extensions, selected, func(p pluginpkg.InstalledPackage) string { return p.Installed.Name }); err != nil {
return err
}
if binding.Digest == "" {
for _, p := range selected {
if err := b.retainExtension(p); err != nil {
return err
}
}
if binding.Resolved.Extensions.Mode == agentcomposition.Inherit {
binding.Resolved.Extensions = selectionOf(selected, func(p pluginpkg.InstalledPackage) string { return p.Installed.Name })
}
}
if err := b.prepareCompositionCommands(); err != nil {
return err
}
return b.prepareCompositionHooks(selected)
}
func (b *builder) prepareCompositionCommands() error {
binding := b.opts.Composition
if binding.Digest != "" {
return nil
}
cmds, err := command.LoadRoots(b.cfg.Roots().CommandRootsForRoot(b.root)...)
if err != nil {
return err
}
kept := []command.Command{}
for _, c := range cmds {
if member(binding.Resolved.Commands, c.Name) && (c.Plugin == "" || member(binding.Resolved.Extensions, c.Plugin)) {
kept = append(kept, c)
}
}
if binding.Digest == "" {
if err := verifySelected(binding.Resolved.Commands, kept, func(c command.Command) string { return c.Name }); err != nil {
return err
}
for _, c := range kept {
if err := keepResource(binding, "command", c.Name, c); err != nil {
return err
}
}
binding.Resolved.Commands = selectionOf(kept, func(c command.Command) string { return c.Name })
}
return nil
}
func (b *builder) prepareCompositionHooks(selected []pluginpkg.InstalledPackage) error {
binding := b.opts.Composition
if binding.Digest != "" {
return nil
}
hooks := hook.Load(hook.LoadOptions{ProjectRoot: b.root, ReasonixHomeDir: b.roots.Home()})
for i, h := range hooks {
id := fmt.Sprintf("%s:%s:%d", h.Scope, h.Event, i)
if !member(binding.Resolved.Hooks, id) {
continue
}
if h.Scope == hook.ScopePlugin {
allowed := false
for _, p := range selected {
if rel, err := filepath.Rel(p.Package.Root, h.Source); err == nil && filepath.IsLocal(rel) {
allowed = true
}
}
if !allowed {
continue
}
}
if binding.Digest == "" {
if err := b.retainHook(id, h); err != nil {
return err
}
}
}
if binding.Digest == "" {
ids := []string{}
for _, r := range binding.Resources {
if r.Kind == "hook" {
ids = append(ids, r.ID)
}
}
if err := verifySelected(binding.Resolved.Hooks, ids, func(id string) string { return id }); err != nil {
return err
}
binding.Resolved.Hooks = selectionOf(ids, func(id string) string { return id })
}
return nil
}
@@ -0,0 +1,70 @@
package boot
import (
"fmt"
"maps"
"path/filepath"
"slices"
"strings"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/ext/pluginpkg"
"reasonix/internal/state/agentpreset"
)
func rebaseCompositionPath(value, from, to string) string {
prefix := ""
if key, path, ok := strings.Cut(value, "="); ok && strings.HasPrefix(key, "--") {
prefix, value = key+"=", path
}
if !filepath.IsAbs(value) {
return prefix + value
}
rel, err := filepath.Rel(from, value)
if err != nil || !filepath.IsLocal(rel) {
return prefix + value
}
return prefix + filepath.Join(to, rel)
}
func (b *builder) rebaseCompositionMCP(entry config.PluginEntry) (config.PluginEntry, error) {
owner, owned := b.cfg.PluginPackageOwner(entry.Name)
if !owned {
return entry, nil
}
if !member(b.opts.Composition.Resolved.Extensions, owner) {
return entry, fmt.Errorf("%w: MCP %s requires extension %s", agentcomposition.ErrUnavailable, entry.Name, owner)
}
installed, _ := pluginpkg.LoadInstalled(b.roots.Home())
retained, err := resource[retainedExtension](b.opts.Composition, "extension")
if err != nil {
return entry, err
}
for _, saved := range retained {
if saved.Package.Installed.Name != owner {
continue
}
for _, live := range installed {
if live.Installed.Name != owner {
continue
}
root, err := agentpreset.New(b.roots.Home(), b.root).Materialize(saved.Assets)
if err != nil {
return entry, err
}
entry.Args = slices.Clone(entry.Args)
entry.Env = maps.Clone(entry.Env)
entry.Source = config.MCPSourcePluginPackage
entry.Command = rebaseCompositionPath(entry.Command, live.Package.Root, root)
for i, arg := range entry.Args {
entry.Args[i] = rebaseCompositionPath(arg, live.Package.Root, root)
}
for key, value := range entry.Env {
entry.Env[key] = rebaseCompositionPath(value, live.Package.Root, root)
}
return entry, nil
}
}
return entry, fmt.Errorf("%w: MCP package resource missing", agentcomposition.ErrUnavailable)
}
@@ -0,0 +1,81 @@
package boot
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
func TestCompositionIsStableAtOpenAIHTTPBoundary(t *testing.T) {
var mu sync.Mutex
var bodies []map[string]json.RawMessage
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
w.WriteHeader(http.StatusBadRequest)
return
}
mu.Lock()
bodies = append(bodies, body)
mu.Unlock()
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprintln(w, `data: {"id":"fixture","model":"x","choices":[{"index":0,"delta":{"content":"done"},"finish_reason":null}]}`)
fmt.Fprintln(w)
fmt.Fprintln(w, `data: {"id":"fixture","model":"x","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`)
fmt.Fprintln(w)
fmt.Fprintln(w, "data: [DONE]")
fmt.Fprintln(w)
}))
defer server.Close()
home, root := compositionTestWorkspace(t, "openai")
writeFile(t, root, "reasonix.toml", fmt.Sprintf("default_model = \"test-model\"\n[[providers]]\nname=\"test-model\"\nkind=\"openai\"\nbase_url=%q\nmodel=\"x\"\n", server.URL+"/v1"))
approveWorkspaceAt(t, home, root)
built, err := BuildRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, Model: "test-model", AgentComposition: "builtin:review", resolvedShell: pinnedEffectShell()})
if err != nil {
t.Fatal(err)
}
defer built.Controller.Close()
built.Controller.EnsureSessionPath()
for _, prompt := range []string{"Read the available information.", "Summarize it briefly."} {
if err := built.Controller.RunTurn(t.Context(), prompt); err != nil {
t.Fatal(err)
}
}
mu.Lock()
defer mu.Unlock()
if len(bodies) < 2 {
t.Fatalf("HTTP requests=%d", len(bodies))
}
var prefix, schemas string
for _, body := range bodies {
var messages []struct{ Role, Content string }
if err := json.Unmarshal(body["messages"], &messages); err != nil {
t.Fatal(err)
}
if len(messages) == 0 || messages[0].Role != "system" || !strings.Contains(messages[0].Content, "rigorous code reviewer") {
t.Fatal("persona absent at wire boundary")
}
if prefix != "" && prefix != messages[0].Content {
t.Fatal("same binding changed system bytes")
}
prefix = messages[0].Content
if schemas != "" && schemas != string(body["tools"]) {
t.Fatal("same binding changed tool schema bytes")
}
schemas = string(body["tools"])
raw, _ := json.Marshal(body)
if strings.Contains(string(raw), "agent_composition") || strings.Contains(string(raw), "builtin:review") {
t.Fatal("binding metadata entered request")
}
for _, name := range []string{"bash", "task", "write_file", "use_capability"} {
if strings.Contains(schemas, `"name":"`+name+`"`) {
t.Fatalf("forbidden wire tool %s", name)
}
}
}
}
@@ -0,0 +1,13 @@
package boot
import "reasonix/internal/session/control"
func inheritComposition(old *control.Controller, opts Options) Options {
if opts.Composition == nil && opts.AgentComposition == "" {
opts.Composition = old.CompositionBinding()
}
if opts.Composition != nil {
opts.ForceFullRebuild = true
}
return opts
}
@@ -0,0 +1,97 @@
package boot
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/ext/hook"
"reasonix/internal/state/agentpreset"
)
func TestCompositionRetainsPersonaSkillsCommandsAndHooksAcrossSourceDeletion(t *testing.T) {
home, root := compositionTestWorkspace(t, "openai")
skillPath := filepath.Join(root, ".reasonix", "skills", "frozen", "SKILL.md")
commandPath := filepath.Join(root, ".reasonix", "commands", "frozen.md")
writeFile(t, root, ".reasonix/skills/frozen/SKILL.md", "---\nname: frozen\ndescription: frozen skill\nuser-invocable: false\n---\nORIGINAL SKILL")
writeFile(t, root, ".reasonix/commands/frozen.md", "ORIGINAL COMMAND")
script := filepath.Join(home, "scripts", "startup.txt")
writeFile(t, home, "scripts/startup.txt", "ORIGINAL HOOK")
settings := hook.Settings{Hooks: map[hook.Event][]hook.HookConfig{hook.SessionStart: {{Command: "cat " + script}}}}
raw, _ := json.Marshal(settings)
writeFile(t, home, hook.SettingsFilename, string(raw))
store := agentpreset.New(home, root)
d := agentcomposition.Builtins()[0]
d.ID = "user:frozen"
d.Persona.Prefix = "ORIGINAL PERSONA"
version, err := store.Save(d, "")
if err != nil {
t.Fatal(err)
}
opts := Options{Home: home, WorkspaceRoot: root, Model: "test-model", AgentComposition: d.ID, resolvedShell: pinnedEffectShell()}
first, err := BuildRuntime(t.Context(), opts)
if err != nil {
t.Fatal(err)
}
defer first.Controller.Close()
if got := first.Controller.HookRunner().SessionStart(t.Context()); len(got) != 1 || got[0] != "ORIGINAL HOOK" {
t.Fatalf("initial hook=%q", got)
}
if len(first.Controller.Skills()) == 0 {
t.Fatal("model-only skill omitted")
}
for _, path := range []string{skillPath, commandPath, script} {
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
}
d.Persona.Prefix = "NEW PERSONA"
updated, err := store.Save(d, version.Revision)
if err != nil {
t.Fatal(err)
}
if err := store.Delete(d.ID, updated.Revision); err != nil {
t.Fatal(err)
}
if err := first.Controller.ReloadCommands(t.Context()); err != nil {
t.Fatal(err)
}
if got := first.Controller.Commands(); len(got) != 1 || !strings.Contains(got[0].Body, "ORIGINAL COMMAND") {
t.Fatal("reload followed command source")
}
opts.AgentComposition, opts.Composition = "", first.Controller.CompositionBinding()
restored, err := BuildRuntime(t.Context(), opts)
if err != nil {
t.Fatal(err)
}
defer restored.Controller.Close()
if restored.Controller.CompositionBinding().Digest != opts.Composition.Digest || !strings.Contains(restored.Snapshot.SystemPrompt(), "ORIGINAL PERSONA") {
t.Fatal("restoration followed definition edit")
}
found := false
for _, sk := range restored.Controller.Skills() {
if sk.Name == "frozen" && strings.Contains(sk.Body, "ORIGINAL SKILL") {
found = true
}
}
if !found {
t.Fatal("deleted skill source lost")
}
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) {
t.Fatalf("deleted definition remained selectable: %v", err)
}
if err := config.DefaultActivationStore().SetSkillEnabled("frozen", root, config.ActivationProject, false); err != nil {
t.Fatal(err)
}
if err := restored.Controller.CompositionExecutionError(); !errors.Is(err, agentcomposition.ErrUnavailable) {
t.Fatalf("revocation ignored: %v", err)
}
}
@@ -0,0 +1,59 @@
package boot
import (
"encoding/json"
"errors"
"path/filepath"
"strings"
"testing"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/ext/hook"
"reasonix/internal/ext/plugin"
"reasonix/internal/ext/pluginpkg"
)
func TestCompositionRetainsNoManifestCredentials(t *testing.T) {
home, root := compositionTestWorkspace(t, "openai")
writeFile(t, root, ".mcp.json", `{"mcpServers":{"docs":{"command":"server","args":["--token","private-fixture-secret"],"headers":{"Authorization":"private-fixture-secret"}}}}`)
p := pluginpkg.InstalledPackage{Installed: pluginpkg.InstalledPlugin{Name: "docs", Source: "https://[email protected]"}}
p.Package.Root = root
p.Package.Manifest.MCPServers = map[string]pluginpkg.MCPServer{"docs": {Command: "server", Args: []string{"--token", "private-fixture-secret"}}}
b := &builder{roots: config.RootsForHome(home), root: root, opts: Options{Composition: &agentcomposition.Binding{}}}
if err := b.retainExtension(p); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(b.opts.Composition.Resources)
if strings.Contains(string(raw), "private-fixture-secret") {
t.Fatal("credentials entered retained package metadata")
}
resources, err := resource[retainedExtension](b.opts.Composition, "extension")
if err != nil {
t.Fatal(err)
}
for _, file := range resources[0].Assets.Files {
if strings.Contains(string(file.Data), "private-fixture-secret") {
t.Fatal("credentials entered retained package files")
}
}
h := hook.ResolvedHook{}
h.Command = "curl --token private-fixture-secret https://example.test"
if err := b.retainHook("user:hook", h); !errors.Is(err, agentcomposition.ErrUnavailable) {
t.Fatalf("literal hook credentials accepted: %v", err)
}
}
func TestCompositionCannotReuseRevokedMCPAuthorization(t *testing.T) {
home, root := compositionTestWorkspace(t, "openai")
entry := config.PluginEntry{Name: "docs", Command: filepath.Join(root, "server"), Source: config.MCPSourceUserConfig}
if err := config.DefaultActivationStore().SetServerEnabled(entry, root, config.ActivationGlobal, false); err != nil {
t.Fatal(err)
}
b := &builder{roots: config.RootsForHome(home), root: root, cfg: &config.Config{Plugins: []config.PluginEntry{entry}}, opts: Options{Composition: &agentcomposition.Binding{Resolved: agentcomposition.Builtins()[1]}}}
b.tools.host = plugin.NewHost()
defer b.tools.host.Close()
if err := b.validateCompositionAvailability(); !errors.Is(err, agentcomposition.ErrUnavailable) {
t.Fatalf("revoked authorization accepted: %v", err)
}
}
@@ -0,0 +1,48 @@
package boot
import (
"context"
"testing"
"reasonix/internal/contract/provider"
)
func TestCompositionRestrictsRealRegistryAndRetainsBinding(t *testing.T) {
home := isolateConfigHome(t)
root := robustTempDir(t)
writeFile(t, root, "reasonix.toml", `default_model = "test-model"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
`)
approveWorkspaceAt(t, home, root)
first, err := BuildRuntime(context.Background(), Options{Home: home, WorkspaceRoot: root, AgentComposition: "builtin:review", resolvedShell: pinnedEffectShell()})
if err != nil {
t.Fatal(err)
}
defer first.Controller.Close()
for _, name := range first.Assembly.Registry.AllNames() {
if name == "bash" || name == "task" || name == "write_file" || name == "install_skill" {
t.Fatalf("restricted tool %s registered", name)
}
}
b := first.Controller.CompositionBinding()
if b == nil || b.Version.Definition.ID != "builtin:review" {
t.Fatalf("binding missing: %+v", b)
}
second, err := BuildRuntime(context.Background(), Options{Home: home, WorkspaceRoot: root, Composition: b, resolvedShell: pinnedEffectShell()})
if err != nil {
t.Fatal(err)
}
defer second.Controller.Close()
if first.Snapshot.CacheHash() != second.Snapshot.CacheHash() {
t.Fatal("pinned rebuild changed provider prefix")
}
for _, m := range provider.ModelMessages(first.Controller.History()) {
if m.AgentComposition != nil {
t.Fatal("binding leaked")
}
}
}
+1 -1
View File
@@ -83,7 +83,7 @@ func startExtensions(ctx context.Context, opts Options, roots config.Roots, root
// Extension sidecars are programs the run would start; the posture holds none.
home = ""
}
mgr, err := preflightExtensionRuntimes(ctx, home, ext.boot(), opts.Extensions, planForPreflight(opts, ext.generation))
mgr, err := preflightExtensionRuntimes(ctx, home, ext.boot(), opts.Extensions, planForPreflight(opts, ext.generation), opts.Composition)
if err != nil {
return nil, fmt.Errorf("boot: %w", err)
}
+5 -4
View File
@@ -27,10 +27,11 @@ type extensionTool struct {
clients *sidecar.Manager
}
func (t extensionTool) Name() string { return t.name }
func (t extensionTool) Description() string { return t.decl.Description }
func (t extensionTool) Schema() json.RawMessage { return t.decl.InputSchema }
func (t extensionTool) ReadOnly() bool { return t.decl.ReadOnly }
func (t extensionTool) Name() string { return t.name }
func (t extensionTool) Description() string { return t.decl.Description }
func (t extensionTool) Schema() json.RawMessage { return t.decl.InputSchema }
func (t extensionTool) ReadOnly() bool { return t.decl.ReadOnly }
func (t extensionTool) ExtensionPackageName() string { return t.pluginID }
func (t extensionTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
client := t.clients.Client(t.pluginID)
+6 -3
View File
@@ -60,9 +60,12 @@ func observeOverrides(opts Options) Options {
// however late, can put a tool the ceiling refuses in reach of the model.
func newToolRegistry(opts Options) *tool.Registry {
reg := tool.NewRegistry()
if opts.Observe != nil {
reg.Restrict(observeAdmits(opts.Observe.Tools))
}
reg.Restrict(func(t tool.Tool) bool {
if opts.Composition != nil && !compositionAdmits(opts.Composition.Resolved)(t) {
return false
}
return opts.Observe == nil || observeAdmits(opts.Observe.Tools)(t)
})
return reg
}
+4 -1
View File
@@ -5,6 +5,7 @@ import (
"reasonix/internal/state/sessionstore"
"time"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/platform/browser"
"reasonix/internal/platform/feedback"
@@ -32,7 +33,9 @@ import (
// run/serve pass true so a missing key fails fast, chat/desktop false so the UI
// is reachable before one is set.
type Options struct {
ComputerDriver *computer.Driver
AgentComposition string
Composition *agentcomposition.Binding
ComputerDriver *computer.Driver
// Version is the host declaring a real installation, which is what lets an
// assembly record the last-known-good config snapshot recovery reads back.
Version string
+3 -2
View File
@@ -57,6 +57,7 @@ func rebuildWithPrevious(ctx context.Context, old *control.Controller, previous
if old == nil {
return nil, fmt.Errorf("boot: Rebuild requires the controller being replaced")
}
opts = inheritComposition(old, opts)
if _, observed := old.ObservePosture(); observed && opts.Observe == nil {
return nil, ErrObserveRebuild
}
@@ -82,7 +83,7 @@ func rebuildWithPrevious(ctx context.Context, old *control.Controller, previous
var fromGraph *extension.DependencyGraph
if previous != nil && previous.Plan != nil && previous.Plan.Graph != nil {
fromGraph = previous.Plan.Graph
} else if g, err := buildRuntimeGraph(home, nil); err == nil {
} else if g, err := buildRuntimeGraph(home, nil, opts.Composition); err == nil {
fromGraph = g
}
opts.Graph = fromGraph
@@ -103,7 +104,7 @@ func rebuildWithPrevious(ctx context.Context, old *control.Controller, previous
}
var toGraph *extension.DependencyGraph
if g, err := buildRuntimeGraph(home, nil); err == nil {
if g, err := buildRuntimeGraph(home, nil, opts.Composition); err == nil {
toGraph = g
}
var previousSnapshot *extension.RuntimeSnapshot
+19 -2
View File
@@ -6,6 +6,7 @@ import (
"strings"
"sync/atomic"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/ext/command"
@@ -164,11 +165,20 @@ var startExtensionPackages = sidecar.StartPackagesWithPlan
// preflightExtensionRuntimes starts enabled runtime packages before model
// resolution. Required failures are fatal; optional failures warn. With
// prev+plan, only Added/Reloaded start and Unchanged are adopted.
func preflightExtensionRuntimes(ctx context.Context, home string, ext extensionBoot, prev *sidecar.Manager, plan *extension.RuntimePlan) (*sidecar.Manager, error) {
func preflightExtensionRuntimes(ctx context.Context, home string, ext extensionBoot, prev *sidecar.Manager, plan *extension.RuntimePlan, bindings ...*agentcomposition.Binding) (*sidecar.Manager, error) {
if strings.TrimSpace(home) == "" {
return nil, nil
}
packages, loadWarnings := sidecar.LoadRuntimePackages(home)
var binding *agentcomposition.Binding
if len(bindings) > 0 {
binding = bindings[0]
}
var selectErr error
packages, selectErr = compositionPackages(home, packages, binding)
if selectErr != nil {
return nil, selectErr
}
if len(packages) == 0 {
// No v2 runtime packages: surface the installed-state warnings exactly
// as the in-assembly StartPackages call did, and take the untouched
@@ -185,7 +195,14 @@ func preflightExtensionRuntimes(ctx context.Context, home string, ext extensionB
if ext.ui != nil {
ui = ext.ui
}
mgr, warnings, err := startExtensionPackages(ctx, home, ext.session, ui, prev, plan)
var mgr *sidecar.Manager
var warnings []string
var err error
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)
}
for _, warning := range warnings {
ext.warn(warning)
}
+11 -3
View File
@@ -3,6 +3,7 @@ package boot
import (
"strings"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/ext/extension"
"reasonix/internal/ext/extension/dispatch"
@@ -35,8 +36,15 @@ type RuntimeReload struct {
// buildRuntimeGraph constructs the dependency graph for installed native
// runtime packages. Host-provided capabilities (if any) are included as a
// synthetic "host" component so plugin requirements can resolve.
func buildRuntimeGraph(home string, hostProvides []extensioncontract.Capability) (*extension.DependencyGraph, error) {
func buildRuntimeGraph(home string, hostProvides []extensioncontract.Capability, bindings ...*agentcomposition.Binding) (*extension.DependencyGraph, error) {
packages, _ := sidecar.LoadRuntimePackages(home)
if len(bindings) > 0 {
var err error
packages, err = compositionPackages(home, packages, bindings[0])
if err != nil {
return nil, err
}
}
comps := make([]extension.ComponentDescriptor, 0, len(packages)+1)
if len(hostProvides) > 0 {
comps = append(comps, extension.ComponentDescriptor{
@@ -166,7 +174,7 @@ func planForPreflight(opts Options, toGen uint64) *extension.RuntimePlan {
if opts.Extensions == nil && opts.Graph == nil {
return nil
}
toGraph, err := buildRuntimeGraph(opts.roots().Home(), nil)
toGraph, err := buildRuntimeGraph(opts.roots().Home(), nil, opts.Composition)
if err != nil {
return nil
}
@@ -179,7 +187,7 @@ func finalizeBuildResult(roots config.Roots, res *BuildResult, publish bool) *Bu
if res == nil {
return nil
}
if graph, err := buildRuntimeGraph(roots.Home(), nil); err == nil {
if graph, err := buildRuntimeGraph(roots.Home(), nil, res.Controller.CompositionBinding()); err == nil {
attachPlanAndStatus(res, nil, graph, 0, nil)
}
if publish {
+22 -6
View File
@@ -26,9 +26,15 @@ import (
// loadHooks resolves the hooks, reusing the previous build's discovery when
// its plan allows, and binds the runner to the resolved shell.
func loadHooks(opts Options, roots config.Roots, root string, shell sandbox.Shell, sink event.Sink) ([]hook.ResolvedHook, *hook.Runner) {
func loadHooks(opts Options, roots config.Roots, root string, shell sandbox.Shell, sink event.Sink) ([]hook.ResolvedHook, *hook.Runner, error) {
var resolved []hook.ResolvedHook
if opts.Observe != nil {
if opts.Composition != nil && opts.Observe == nil {
var err error
resolved, err = compositionHookSet(opts, roots.Home(), root)
if err != nil {
return nil, nil, err
}
} else if opts.Observe != nil {
// An empty, non-nil scope list loads no hook at all: nil would load every one.
resolved = hook.Load(hook.LoadOptions{ProjectRoot: root, ReasonixHomeDir: roots.Home(), Scopes: []hook.Scope{}})
} else if opts.ReuseAssembly != nil && shouldReuseDiscovery(opts.PreviousPlan) {
@@ -39,7 +45,7 @@ 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)) })
return resolved, hook.NewRunner(resolved, root, hook.NewDefaultSpawner(hookRuntime(shell)), func(n hook.Notice) { sink.Emit(hookNoticeEvent(n)) }), nil
}
// NewUserHookRunner resolves only the hooks the user configured under the
@@ -89,6 +95,10 @@ func registerSessionTools(reg *tool.Registry, set ablation.Set, roots config.Roo
}
func loadCommands(opts Options, root string) []command.Command {
if opts.Composition != nil {
cmds, _ := resource[command.Command](opts.Composition, "command")
return cmds
}
if opts.ReuseAssembly != nil && shouldReuseDiscovery(opts.PreviousPlan) {
return opts.ReuseAssembly.Commands
}
@@ -163,8 +173,14 @@ type capabilitySurface struct {
catalog func() capability.Catalog
}
func newCapabilitySurface(ctx context.Context, cfg *config.Config, root string, specOptions pluginspec.Options, host *plugin.Host, reg *tool.Registry, skills *skill.Store, profile capability.Profile, enabled map[string]bool) *capabilitySurface {
func newCapabilitySurface(ctx context.Context, cfg *config.Config, root string, specOptions pluginspec.Options, host *plugin.Host, reg *tool.Registry, skills *skill.Store, profile capability.Profile, enabled map[string]bool, extra ...plugin.Spec) *capabilitySurface {
c := &capabilitySurface{specs: pluginspec.ForRootWithOptions(cfg.Plugins, root, specOptions)}
entries := append([]config.PluginEntry(nil), cfg.Plugins...)
for _, spec := range extra {
c.specs = append(c.specs, spec)
entries = append(entries, compositionMCPEntry(spec))
enabled[spec.Name] = true
}
cachedTools, cacheKeyOK := capability.LoadCachedToolsForSpecs(c.specs)
skills.ConfigureToolBindings(func(sk skill.Skill) []tool.MCPBinding {
return skillMCPBindings(sk, reg, c.specs, cachedTools, cacheKeyOK)
@@ -184,7 +200,7 @@ func newCapabilitySurface(ctx context.Context, cfg *config.Config, root string,
catOpts := capability.CatalogOptions{
Tools: reg.AllContractEntries(),
Skills: skills.PathHits().Visible(skill.ModelInvocable(skills.List())),
Plugins: cfg.Plugins,
Plugins: entries,
Profile: profile,
Connected: conn,
Failed: failedNow,
@@ -198,7 +214,7 @@ func newCapabilitySurface(ctx context.Context, cfg *config.Config, root string,
}
// WithoutCancel: ctx is often one request, and MCP children outlive it.
c.runtime = usecap.NewMCPCapabilityRuntime(context.WithoutCancel(ctx), host, c.specs, reg, c.catalog)
c.runtime.ConfigureServers(cfg.Plugins, c.specs, enabled)
c.runtime.ConfigureServers(entries, c.specs, enabled)
c.ledger = capability.NewLedger()
c.audit = &capability.Audit{}
c.proxy = c.runtime.NewFrontend(c.ledger, c.audit)
@@ -0,0 +1,5 @@
{
"builtin:general": "7ff797c7c3d81f3868b923ad1a725953c897d336c3e939ed574c41388a2f23cb",
"builtin:review": "4956bb3789cfbef884f5c13e72128610e6d5579229389e172050266e104841cc",
"builtin:research": "8f839d51ee598c71ef54cf507e9c3039da764ca0b0ee1af2d1368a44b44fed80"
}
@@ -0,0 +1,23 @@
package agentcomposition
import (
"encoding/json"
"os"
"testing"
)
func TestStudioMockBuiltinRevisionsMatchKernel(t *testing.T) {
data, err := os.ReadFile("builtin-revisions.json")
if err != nil {
t.Fatal(err)
}
var revisions map[string]string
if err := json.Unmarshal(data, &revisions); err != nil {
t.Fatal(err)
}
for _, d := range Builtins() {
if revisions[d.ID] != Revision(d) {
t.Fatalf("refresh Studio Mock revision for %s", d.ID)
}
}
}
@@ -0,0 +1,15 @@
package agentcomposition
import (
_ "embed"
"encoding/json"
)
//go:embed builtins.json
var builtinData []byte
func Builtins() []Definition {
var out []Definition
_ = json.Unmarshal(builtinData, &out)
return out
}
@@ -0,0 +1,23 @@
[
{
"schemaVersion": 1, "id": "builtin:general", "name": "通用", "description": "继承当前配置,可读取、执行和修改;操作继续遵守现有权限。",
"persona": {"prefix": "", "suffix": "", "complete": false, "include_runtime_context": true},
"tools": {"mode":"inherit","members":[]}, "skills": {"mode":"inherit","members":[]},
"mcp": {"mode":"inherit","members":[]}, "extensions": {"mode":"inherit","members":[]},
"hooks": {"mode":"inherit","members":[]}, "commands": {"mode":"inherit","members":[]}
},
{
"schemaVersion": 1, "id": "builtin:review", "name": "代码审查", "description": "读取和检索代码,依据证据审查;不运行 shell、不修改文件。",
"persona": {"prefix": "You are a rigorous code reviewer. Read the implementation, identify reachable defects, and support findings with evidence.", "suffix": "", "complete": false, "include_runtime_context": true},
"tools": {"mode":"select","members":[{"id":"read_file"},{"id":"ls"},{"id":"grep"},{"id":"glob"}]},
"skills": {"mode":"select","members":[]}, "mcp": {"mode":"select","members":[]},
"extensions": {"mode":"select","members":[]}, "hooks": {"mode":"select","members":[]}, "commands": {"mode":"select","members":[]}
},
{
"schemaVersion": 1, "id": "builtin:research", "name": "资料研究", "description": "读取代码和文档,搜索与抓取网页;不运行 shell、不修改文件。",
"persona": {"prefix": "You are a research analyst. Read primary sources, distinguish evidence from inference, and cite the sources supporting your conclusions.", "suffix": "", "complete": false, "include_runtime_context": true},
"tools": {"mode":"select","members":[{"id":"read_file"},{"id":"ls"},{"id":"grep"},{"id":"glob"},{"id":"web_search"},{"id":"web_fetch"}]},
"skills": {"mode":"select","members":[]}, "mcp": {"mode":"select","members":[]},
"extensions": {"mode":"select","members":[]}, "hooks": {"mode":"select","members":[]}, "commands": {"mode":"select","members":[]}
}
]
@@ -0,0 +1,199 @@
// Package agentcomposition defines immutable session capability compositions.
package agentcomposition
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"regexp"
"strings"
)
const SchemaVersion = 1
const Inherit = "inherit"
const Select = "select"
var ErrInvalid = errors.New("invalid agent composition")
var ErrUnavailable = errors.New("agent composition unavailable")
var ErrLocked = errors.New("agent composition locked")
var identifier = regexp.MustCompile(`^(builtin|user|project):[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
type Persona struct {
Prefix string `json:"prefix"`
Suffix string `json:"suffix"`
Complete bool `json:"complete"`
IncludeRuntimeContext bool `json:"include_runtime_context"`
}
type Module struct {
ID string `json:"id"`
Parameters json.RawMessage `json:"parameters,omitempty"`
}
type Selection struct {
Mode string `json:"mode"`
Members []Module `json:"members"`
}
type Definition struct {
SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Persona Persona `json:"persona"`
Tools Selection `json:"tools"`
Skills Selection `json:"skills"`
MCP Selection `json:"mcp"`
Extensions Selection `json:"extensions"`
Hooks Selection `json:"hooks"`
Commands Selection `json:"commands"`
}
type Version struct {
Definition Definition `json:"definition"`
Revision string `json:"revision"`
}
type Diagnostic struct {
Code string `json:"code"`
Module string `json:"module,omitempty"`
Detail string `json:"detail"`
}
// Resource captures a module's data without retaining a mutable source path.
type Resource struct {
ID string `json:"id"`
Kind string `json:"kind"`
Data json.RawMessage `json:"data"`
}
type Binding struct {
Resolved Definition `json:"resolved"`
Started bool `json:"started,omitempty"`
Version Version `json:"version"`
Digest string `json:"digest"`
Workspace string `json:"workspace,omitempty"`
Resources []Resource `json:"resources"`
SystemPrompt string `json:"systemPrompt,omitempty"`
ToolSchemaDigest string `json:"toolSchemaDigest,omitempty"`
ToolSurface []string `json:"toolSurface"`
}
func (b *Binding) Seal() {
b.Digest = ""
started := b.Started
b.Started = false
b.Digest = Digest(b)
b.Started = started
}
func (b *Binding) Validate() error {
if b == nil {
return ErrInvalid
}
if err := b.Version.Definition.Validate(); err != nil {
return err
}
if b.Version.Revision != Revision(b.Version.Definition) {
return ErrInvalid
}
if err := b.Resolved.Validate(); err != nil {
return err
}
if b.Resolved.ID != b.Version.Definition.ID {
return ErrInvalid
}
total := len(b.SystemPrompt)
seen := map[string]bool{}
for _, r := range b.Resources {
key := r.Kind + ":" + r.ID
if r.ID == "" || r.Kind == "" || seen[key] || !json.Valid(r.Data) {
return ErrInvalid
}
seen[key] = true
total += len(r.Data)
if total > 128<<20 || len(b.Resources) > 20000 {
return fmt.Errorf("%w: retained resource limit", ErrInvalid)
}
}
copy := Clone(b)
copy.Seal()
if copy.Digest != b.Digest {
return fmt.Errorf("%w: binding digest", ErrInvalid)
}
return nil
}
func (d Definition) Validate() error {
if d.SchemaVersion != SchemaVersion || !identifier.MatchString(d.ID) || strings.TrimSpace(d.Name) == "" || len(d.Name) > 1024 || len(d.Description) > 16384 {
return fmt.Errorf("%w: schemaVersion, id or name", ErrInvalid)
}
if len(d.Persona.Prefix) > 32768 || len(d.Persona.Suffix) > 32768 || d.Persona.Complete && strings.TrimSpace(d.Persona.Prefix) == "" {
return fmt.Errorf("%w: persona", ErrInvalid)
}
for _, s := range []Selection{d.Tools, d.Skills, d.MCP, d.Extensions, d.Hooks, d.Commands} {
if len(s.Members) > 10000 {
return ErrInvalid
}
if s.Mode != Inherit && s.Mode != Select || s.Mode == Inherit && len(s.Members) != 0 {
return fmt.Errorf("%w: selection mode", ErrInvalid)
}
seen := map[string]bool{}
for _, m := range s.Members {
if m.ID == "" || len(m.ID) > 1024 || seen[m.ID] {
return fmt.Errorf("%w: duplicate or empty module %q", ErrInvalid, m.ID)
}
seen[m.ID] = true
if len(m.Parameters) > 0 {
var obj map[string]json.RawMessage
if err := json.Unmarshal(m.Parameters, &obj); err != nil || obj == nil {
return fmt.Errorf("%w: module parameters", ErrInvalid)
}
}
}
}
return d.validateModuleParameters()
}
func Decode(data []byte) (Definition, error) {
var d Definition
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&d); err != nil {
return d, fmt.Errorf("%w: %w", ErrInvalid, err)
}
if err := dec.Decode(new(any)); err != io.EOF {
return d, fmt.Errorf("%w: trailing data", ErrInvalid)
}
return d, d.Validate()
}
func Revision(d Definition) string { return Digest(d) }
func Digest(v any) string {
b, err := json.Marshal(v)
if err != nil {
return ""
}
var normalized any
if err := json.Unmarshal(b, &normalized); err != nil {
return ""
}
b, err = json.Marshal(normalized)
if err != nil {
return ""
}
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func Clone[T any](v T) T {
b, _ := json.Marshal(v)
var out T
_ = json.Unmarshal(b, &out)
return out
}
@@ -0,0 +1,51 @@
package agentcomposition
import (
"errors"
"testing"
)
func TestDefinitionsValidateIdentityAndSelections(t *testing.T) {
d := Builtins()[0]
if err := d.Validate(); err != nil {
t.Fatal(err)
}
d.ID = "user:../escape"
if !errors.Is(d.Validate(), ErrInvalid) {
t.Fatal("path identity accepted")
}
d = Builtins()[1]
d.Skills = Selection{Mode: Select, Members: []Module{{ID: "one"}, {ID: "one"}}}
if !errors.Is(d.Validate(), ErrInvalid) {
t.Fatal("duplicate member accepted")
}
}
func TestRevisionCanonicalizesParameterObjects(t *testing.T) {
a := Builtins()[0]
a.MCP = Selection{Mode: Select, Members: []Module{{ID: "example", Parameters: []byte(`{"b":2,"a":1}`)}}}
b := a
b.MCP.Members = []Module{{ID: "example", Parameters: []byte(`{"a":1,"b":2}`)}}
if Revision(a) != Revision(b) {
t.Fatal("object order changed revision")
}
b.Name = "Changed"
if Revision(a) == Revision(b) {
t.Fatal("definition edit did not change revision")
}
}
func TestRestrictedTemplatesHaveNoAutomationOrDelegation(t *testing.T) {
for _, d := range Builtins()[1:] {
for _, s := range []Selection{d.Skills, d.MCP, d.Extensions, d.Hooks, d.Commands} {
if s.Mode != Select || len(s.Members) != 0 {
t.Fatalf("%s inherits modules", d.ID)
}
}
for _, m := range d.Tools.Members {
if m.ID == "bash" || m.ID == "task" || m.ID == "write_file" {
t.Fatalf("%s carries %s", d.ID, m.ID)
}
}
}
}
@@ -0,0 +1,18 @@
package agentcomposition
import "strings"
// DiagnosticError preserves module identities across transport boundaries.
type DiagnosticError struct{ Diagnostics []Diagnostic }
func (e *DiagnosticError) Error() string {
lines := []string{ErrUnavailable.Error()}
for _, d := range e.Diagnostics {
lines = append(lines, d.Module+": "+d.Detail)
}
return strings.Join(lines, "; ")
}
func (e *DiagnosticError) Unwrap() error { return ErrUnavailable }
func Unavailable(module, detail string) error {
return &DiagnosticError{Diagnostics: []Diagnostic{{Code: "agent_composition.unavailable", Module: module, Detail: detail}}}
}
@@ -0,0 +1,57 @@
package agentcomposition
import (
"bytes"
"encoding/json"
"fmt"
"reasonix/internal/base/secrets"
"regexp"
"strings"
)
type Parameters struct {
Args *[]string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
URL *string `json:"url,omitempty"`
CallTimeoutSeconds *int `json:"callTimeoutSeconds,omitempty"`
StartupTimeoutSeconds *int `json:"startupTimeoutSeconds,omitempty"`
TimeoutMillis *int `json:"timeoutMillis,omitempty"`
}
var environmentReference = regexp.MustCompile(`^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$`)
func ParseParameters(raw json.RawMessage) (Parameters, error) {
var p Parameters
if len(raw) == 0 {
return p, nil
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return p, fmt.Errorf("%w: module parameters: %w", ErrInvalid, err)
}
for _, pairs := range []map[string]string{p.Env, p.Headers} {
for _, value := range pairs {
if !environmentReference.MatchString(value) {
return p, fmt.Errorf("%w: environment and header values must be ${NAME} references", ErrInvalid)
}
}
}
if p.CallTimeoutSeconds != nil && *p.CallTimeoutSeconds < 0 || p.StartupTimeoutSeconds != nil && *p.StartupTimeoutSeconds < 0 || p.TimeoutMillis != nil && *p.TimeoutMillis < 0 {
return p, fmt.Errorf("%w: negative timeout", ErrInvalid)
}
if p.Args != nil {
projected := secrets.RedactArgs(*p.Args)
for i, arg := range *p.Args {
_, value, inline := strings.Cut(arg, "=")
if projected[i] != arg && !environmentReference.MatchString(arg) && !(inline && environmentReference.MatchString(value)) {
return p, fmt.Errorf("%w: credentials require references", ErrInvalid)
}
}
}
if p.URL != nil && !environmentReference.MatchString(*p.URL) && secrets.RedactEndpoint(*p.URL) != *p.URL {
return p, fmt.Errorf("%w: endpoint credentials require references", ErrInvalid)
}
return p, nil
}
@@ -0,0 +1,39 @@
package agentcomposition
import "fmt"
func (d Definition) validateModuleParameters() error {
for _, s := range []Selection{d.MCP, d.Extensions} {
for _, m := range s.Members {
if _, err := ParseParameters(m.Parameters); err != nil {
return err
}
}
}
for _, m := range d.Extensions.Members {
p, err := ParseParameters(m.Parameters)
if err != nil {
return err
}
if p.URL != nil || p.Headers != nil || p.CallTimeoutSeconds != nil || p.StartupTimeoutSeconds != nil {
return fmt.Errorf("%w: unsupported extension parameter", ErrInvalid)
}
}
for _, m := range d.MCP.Members {
p, err := ParseParameters(m.Parameters)
if err != nil {
return err
}
if p.TimeoutMillis != nil {
return fmt.Errorf("%w: unsupported MCP parameter", ErrInvalid)
}
}
for _, s := range []Selection{d.Tools, d.Skills, d.Hooks, d.Commands} {
for _, m := range s.Members {
if len(m.Parameters) > 0 && string(m.Parameters) != "{}" {
return fmt.Errorf("%w: parameters unsupported for module %s", ErrInvalid, m.ID)
}
}
}
return nil
}
+6 -8
View File
@@ -11,8 +11,6 @@ import (
"reasonix/internal/base/testenv"
)
func boolValue(v bool) *bool { return &v }
func TestPersonaNormalizeTrimsAndCollapsesOnlyTheEmptyChoice(t *testing.T) {
zero := PersonaConfig{}.Normalize()
if !reflect.DeepEqual(zero, PersonaConfig{}) {
@@ -29,7 +27,7 @@ func TestPersonaNormalizeTrimsAndCollapsesOnlyTheEmptyChoice(t *testing.T) {
// An explicit runtime-context choice is a contribution by itself: the Studio
// switch may be the only field the user touched, and collapsing it would make
// the save read back as unchanged.
pointerOnly := PersonaConfig{IncludeRuntimeContext: boolValue(false)}.Normalize()
pointerOnly := PersonaConfig{IncludeRuntimeContext: new(false)}.Normalize()
if pointerOnly.IsZero() || pointerOnly.IncludeRuntimeContext == nil || *pointerOnly.IncludeRuntimeContext {
t.Fatalf("pointer-only persona collapsed or lost: %+v", pointerOnly)
}
@@ -70,10 +68,10 @@ func TestPersonaRuntimeContextDefaultsEnabled(t *testing.T) {
if !(PersonaConfig{}).RuntimeContextEnabled() {
t.Fatal("absent include_runtime_context reads as disabled")
}
if !(PersonaConfig{IncludeRuntimeContext: boolValue(true)}).RuntimeContextEnabled() {
if !(PersonaConfig{IncludeRuntimeContext: new(true)}).RuntimeContextEnabled() {
t.Fatal("explicit true reads as disabled")
}
if (PersonaConfig{IncludeRuntimeContext: boolValue(false)}).RuntimeContextEnabled() {
if (PersonaConfig{IncludeRuntimeContext: new(false)}).RuntimeContextEnabled() {
t.Fatal("explicit false reads as enabled")
}
}
@@ -85,10 +83,10 @@ func TestPersonaRendersAndLoadsUnchanged(t *testing.T) {
t.Setenv("REASONIX_HOME", home)
path := UserConfigPath()
for _, want := range []PersonaConfig{
{Prefix: "IDENTITY", Suffix: "TAIL", Complete: true, IncludeRuntimeContext: boolValue(false)},
{Prefix: "IDENTITY", IncludeRuntimeContext: boolValue(true)},
{Prefix: "IDENTITY", Suffix: "TAIL", Complete: true, IncludeRuntimeContext: new(false)},
{Prefix: "IDENTITY", IncludeRuntimeContext: new(true)},
{Suffix: "TAIL"},
{IncludeRuntimeContext: boolValue(false)},
{IncludeRuntimeContext: new(false)},
} {
c := Default()
if err := c.SetPersona(want); err != nil {
@@ -0,0 +1,10 @@
package config
import "maps"
// WithCredentialContextFrom restores only live provenance and scoped dotenv lookup.
func (e PluginEntry) WithCredentialContextFrom(live PluginEntry) PluginEntry {
e.Source = live.Source
e.expansionEnv = maps.Clone(live.expansionEnv)
return e
}
@@ -174,6 +174,7 @@ func RootIDs() []RootID {
var StateRootEntries = []string{
"sessions", "archive", "stats", "projects", "appearance", "themes", "repair",
"serve-workspaces.json", "memory", "schedules", "peers", "trusted", "machine-id.key", "mcp-global-migration-v1", "commands",
"agent-presets",
"REASONIX.md", "AGENTS.md", "CLAUDE.md", "REASONIX.local.md", "AGENTS.local.md", "CLAUDE.local.md",
}
+2 -1
View File
@@ -21,7 +21,7 @@ func ProjectionMessages(msgs []Message) []Message { return projectMessages(msgs,
func projectMessages(msgs []Message, keepExecution bool) []Message {
needsCopy := false
for _, m := range msgs {
if (len(m.UnavailableImages) > 0 && !keepExecution) || m.LocalOnly || m.RawContent != "" || m.ProviderContent != "" || m.HostAuthored || m.ModelRef != "" || m.Via != nil || m.DecisionReceipt != nil || len(m.DecisionReceipts) > 0 || (m.ToolExecution != nil && !keepExecution) || (m.ToolFailure != nil && !keepExecution) {
if (m.AgentComposition != nil && !keepExecution) || (len(m.UnavailableImages) > 0 && !keepExecution) || m.LocalOnly || m.RawContent != "" || m.ProviderContent != "" || m.HostAuthored || m.ModelRef != "" || m.Via != nil || m.DecisionReceipt != nil || len(m.DecisionReceipts) > 0 || (m.ToolExecution != nil && !keepExecution) || (m.ToolFailure != nil && !keepExecution) {
needsCopy = true
break
}
@@ -45,6 +45,7 @@ func projectMessages(msgs []Message, keepExecution bool) []Message {
candidate.DecisionReceipt = nil
candidate.DecisionReceipts = nil
if !keepExecution {
candidate.AgentComposition = nil
// Local shell metadata must never enter provider request bytes.
candidate.ToolExecution = nil
candidate.ToolFailure = nil
+3 -1
View File
@@ -16,6 +16,7 @@ import (
"sync"
"reasonix/internal/base/nilutil"
"reasonix/internal/contract/agentcomposition"
)
// Role is the role of a message.
@@ -39,7 +40,8 @@ const (
// Message is a single conversation message.
type Message struct {
Role Role `json:"role"`
AgentComposition *agentcomposition.Binding `json:"agent_composition,omitempty"`
Role Role `json:"role"`
// Content is the provider-visible conversation content. Keeping this legacy
// field provider-visible preserves replay for older CLI/Desktop releases.
Content string `json:"content,omitempty"`
+2
View File
@@ -253,6 +253,8 @@ func applyHooks(items []Item) error {
func writeOne(it Item) error {
switch it.Kind {
case KindAgentPreset:
return restoreCompositionItem(it)
case KindSkill:
var d skillData
if err := json.Unmarshal(it.Data, &d); err != nil {
+7 -1
View File
@@ -58,6 +58,9 @@ func Collect(opts CollectOptions) (*Snapshot, error) {
}
s.Items, s.Omitted = c.items, c.omitted
if slices.ContainsFunc(s.Items, func(it Item) bool { return it.Kind == KindHook }) {
s.Format = 2
}
if slices.ContainsFunc(s.Items, func(it Item) bool { return it.Kind == KindAgentPreset }) {
s.Format = FormatVersion
}
return s, nil
@@ -133,7 +136,10 @@ func (c *collector) settings() error {
if err != nil {
return err
}
return c.add(KindInterface, "interface", raw)
if err := c.add(KindInterface, "interface", raw); err != nil {
return err
}
return c.compositions()
}
func (c *collector) extensions() error {
+63
View File
@@ -0,0 +1,63 @@
package configbackup
import (
"encoding/json"
"errors"
"strings"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/state/agentpreset"
)
const KindAgentPreset = "agent_preset"
type compositionData struct {
Definition agentcomposition.Definition `json:"definition"`
Default bool `json:"default,omitempty"`
}
func (c *collector) compositions() error {
catalog, err := agentpreset.New(config.ReasonixHomeDir(), "").List()
if err != nil {
return err
}
for _, v := range catalog.Presets {
if strings.HasPrefix(v.Definition.ID, "user:") {
if err := c.add(KindAgentPreset, v.Definition.ID, compositionData{v.Definition, v.Definition.ID == catalog.Default}); err != nil {
return err
}
}
}
return nil
}
func decodeCompositionItem(it Item) (compositionData, error) {
var data compositionData
if err := json.Unmarshal(it.Data, &data); err != nil {
return data, err
}
if !strings.HasPrefix(it.Name, "user:") || it.Name != data.Definition.ID {
return data, ErrMalformed
}
return data, data.Definition.Validate()
}
func restoreCompositionItem(it Item) error {
data, err := decodeCompositionItem(it)
if err != nil {
return err
}
store := agentpreset.New(config.ReasonixHomeDir(), "")
old, err := store.Get(it.Name)
if err != nil && !errors.Is(err, agentpreset.ErrNotFound) {
return err
}
if _, err := store.Save(data.Definition, old.Revision); err != nil {
return err
}
if data.Default {
return store.SetDefault(it.Name, false)
}
return nil
}
@@ -0,0 +1,53 @@
package configbackup
import (
"testing"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/state/agentpreset"
)
func TestCompositionBackupRetainsDeclarationsButRequiresExplicitRestore(t *testing.T) {
machine(t, userConfig)
store := agentpreset.New(config.ReasonixHomeDir(), "")
d := agentcomposition.Builtins()[1]
d.ID = "user:review-copy"
d.Persona.Suffix = "custom tail"
v, err := store.Save(d, "")
must(t, err)
must(t, store.SetDefault(d.ID, false))
snapshot, err := Collect(CollectOptions{Categories: []Category{CategorySettings}})
must(t, err)
if snapshot.Format != 3 {
t.Fatalf("format=%d", snapshot.Format)
}
var preset Item
for _, it := range snapshot.Items {
if it.Kind == KindAgentPreset {
preset = it
}
}
if preset.ID == "" {
t.Fatal("preset omitted")
}
must(t, store.Delete(d.ID, v.Revision))
if consentFor(preset, snapshot, &localState{}) != ConsentExecutes {
t.Fatal("restore omitted consent")
}
must(t, restoreCompositionItem(preset))
restored, err := store.Get(d.ID)
must(t, err)
if restored.Revision != v.Revision {
t.Fatal("backup changed declaration")
}
def, err := store.Default()
must(t, err)
if def != d.ID {
t.Fatal("personal default lost")
}
snapshot.Format = 2
if snapshot.validate() == nil {
t.Fatal("old backup boundary ignored new item")
}
}
+4
View File
@@ -108,6 +108,10 @@ func (s *Snapshot) validateRestorable() error {
readers := map[string]bool{}
for _, it := range s.Items {
switch it.Kind {
case KindAgentPreset:
if _, err := decodeCompositionItem(it); err != nil || s.Format < 3 {
return fmt.Errorf("%w: Agent preset", ErrMalformed)
}
case KindProvider:
var p config.ProviderEntry
if err := decodeTOML(it.Data, &p); err != nil || p.Name != it.Name {
+6
View File
@@ -17,6 +17,10 @@ const maxShownContent = 16 << 10
// endpoint a key goes to, the file that will be written.
func describe(it Item) (string, []PathRef) {
switch it.Kind {
case KindAgentPreset:
if data, err := decodeCompositionItem(it); err == nil {
return data.Definition.Name + " · " + data.Definition.Description, nil
}
case KindProvider:
var p config.ProviderEntry
if decodeTOML(it.Data, &p) == nil {
@@ -67,6 +71,8 @@ func describe(it Item) (string, []PathRef) {
// where data goes, so consent is given to the whole of it, not its first line.
func details(it Item) ([]string, string) {
switch it.Kind {
case KindAgentPreset:
return nil, string(it.Data)
case KindProvider:
var p config.ProviderEntry
if decodeTOML(it.Data, &p) != nil {
+1 -1
View File
@@ -227,5 +227,5 @@ func canonical(raw json.RawMessage) []byte {
}
func executes(kind string) bool {
return kind == KindHook || kind == KindStatusline || kind == KindMCP
return kind == KindHook || kind == KindStatusline || kind == KindMCP || kind == KindAgentPreset
}
+2 -2
View File
@@ -15,7 +15,7 @@ import (
// FormatVersion is the latest snapshot schema this build writes. A reader refuses a
// newer one rather than guessing at fields it does not know. Version 2 adds
// complete hook identities; version 1 remains readable.
const FormatVersion = 2
const FormatVersion = 3
// Category is one box the user ticks at export.
type Category string
@@ -186,7 +186,7 @@ func (s *Snapshot) validate() error {
// settings to dodge the consent automation carries.
func categoryOfKind(kind string) Category {
switch kind {
case KindGeneral, KindProvider, KindInterface:
case KindGeneral, KindProvider, KindInterface, KindAgentPreset:
return CategorySettings
case KindSkill, KindPlugin, KindMCP:
return CategoryExtensions
@@ -0,0 +1,35 @@
package sidecar
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/ext/pluginpkg"
)
func TestCompositionExtensionRuntimeCopiesDoNotShareMutableFiles(t *testing.T) {
source := t.TempDir()
if err := os.WriteFile(filepath.Join(source, "entry"), []byte("original"), 0o700); err != nil {
t.Fatal(err)
}
p := pluginpkg.InstalledPackage{}
p.Package.Root = source
first, err := privateCompositionPackages(t.TempDir(), []pluginpkg.InstalledPackage{p})
if err != nil {
t.Fatal(err)
}
second, err := privateCompositionPackages(t.TempDir(), []pluginpkg.InstalledPackage{p})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(first[0].Package.Root, "entry"), []byte("session one"), 0o700); err != nil {
t.Fatal(err)
}
for _, root := range []string{source, second[0].Package.Root} {
data, err := os.ReadFile(filepath.Join(root, "entry"))
if err != nil || string(data) != "original" {
t.Fatalf("mutable runtime leaked into %s: %v", root, err)
}
}
}
+10 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"os"
"sort"
"strings"
"sync"
@@ -69,9 +70,10 @@ func LoadRuntimePackages(home string) ([]pluginpkg.InstalledPackage, []string) {
// alone. It implements io.Closer so the kernel's RuntimeSet can retire it
// with its controller generation.
type Manager struct {
mu sync.Mutex
clients map[string]*Client
closed bool
mu sync.Mutex
clients map[string]*Client
closed bool
privateRoots []string
// planAdopted records clients moved from a previous manager during
// StartPackagesWithPlan (Unchanged). RollbackPlanStart reattaches only
// these; newly started Added/Reloaded clients are closed by m.Close().
@@ -194,6 +196,8 @@ func (m *Manager) Close() error {
}
m.clients = nil
m.planAdopted = nil
privateRoots := m.privateRoots
m.privateRoots = nil
m.mu.Unlock()
var wg sync.WaitGroup
@@ -205,6 +209,9 @@ func (m *Manager) Close() error {
}(client)
}
wg.Wait()
for _, root := range privateRoots {
_ = os.RemoveAll(root)
}
return nil
}
@@ -0,0 +1,79 @@
package sidecar
import (
"context"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"reasonix/internal/ext/extension/protocol"
"reasonix/internal/ext/pluginpkg"
)
// StartResolvedPackages starts the caller's authorized, session-scoped selection.
func StartResolvedPackages(ctx context.Context, packages []pluginpkg.InstalledPackage, session protocol.SessionContext, ui UIHandler) (*Manager, []string, error) {
startup, cancel := context.WithTimeout(ctx, packageStartupBudget)
defer cancel()
root, err := os.MkdirTemp("", "reasonix-composition-extensions-")
if err != nil {
return nil, nil, err
}
private, err := privateCompositionPackages(root, packages)
if err != nil {
_ = os.RemoveAll(root)
return nil, nil, err
}
mgr, warnings, err := startLoadedPackages(startup, private, session, ui, StartClient)
if err != nil {
_ = os.RemoveAll(root)
return mgr, warnings, err
}
mgr.mu.Lock()
mgr.privateRoots = append(mgr.privateRoots, root)
mgr.mu.Unlock()
return mgr, warnings, nil
}
func privateCompositionPackages(root string, packages []pluginpkg.InstalledPackage) ([]pluginpkg.InstalledPackage, error) {
out := make([]pluginpkg.InstalledPackage, 0, len(packages))
for i, p := range packages {
private := filepath.Join(root, fmt.Sprintf("%d", i))
if err := os.CopyFS(private, os.DirFS(p.Package.Root)); err != nil {
return nil, err
}
if rt := p.Package.Manifest.Runtime; rt != nil {
copy := *rt
copy.Args = slices.Clone(rt.Args)
copy.Env = maps.Clone(rt.Env)
copy.Command = privatePackagePath(copy.Command, p.Package.Root, private)
for i, arg := range copy.Args {
copy.Args[i] = privatePackagePath(arg, p.Package.Root, private)
}
for key, value := range copy.Env {
copy.Env[key] = privatePackagePath(value, p.Package.Root, private)
}
p.Package.Manifest.Runtime = &copy
}
p.Package.Root = private
out = append(out, p)
}
return out, nil
}
func privatePackagePath(value, from, to string) string {
prefix := ""
if key, path, ok := strings.Cut(value, "="); ok && strings.HasPrefix(key, "--") {
prefix, value = key+"=", path
}
if !filepath.IsAbs(value) {
return prefix + value
}
rel, err := filepath.Rel(from, value)
if err != nil || !filepath.IsLocal(rel) {
return prefix + value
}
return prefix + filepath.Join(to, rel)
}
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff. Show more