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
No files matched your search
@@ -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) ·
|
||||
|
||||
@@ -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) ·
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
@@ -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[];
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
</>;
|
||||
}
|
||||
@@ -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("模型服务")}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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 中提供中英文翻译。
|
||||
|
||||
预设名称、版本和诊断不进入缓存前缀。切换到另一套组合首次使用时可能产生
|
||||
冷缓存;编辑预设不会重写正在运行的会话前缀。
|
||||
@@ -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 |
|
||||
|
||||
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 411 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 70 KiB |
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 = ©
|
||||
} 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = ©
|
||||
}
|
||||
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)
|
||||
}
|
||||