mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #13588 from SivanCola/feature/new_system_person_20261017
feat(persona): add a system persona to Studio / 新增 Studio 系统人设
This commit is contained in:
51 files changed
+1615
-51
No files matched your search
@@ -669,6 +669,13 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "auto-archive.notice-dismiss", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "auto-archive.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "auto-archive.days", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
// The persona editor edits a draft; only the save reaches the kernel, and the
|
||||
// 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.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" },
|
||||
|
||||
@@ -12,6 +12,7 @@ import { EN_WINDOW } from "./en_window";
|
||||
import { EN_GRAPH } from "./en_graph";
|
||||
import { EN_TRAJECTORY } from "./en_trajectory";
|
||||
import { EN_SETTINGS } from "./en_settings";
|
||||
import { EN_PERSONA } from "./en_persona";
|
||||
import { EN_METRICS } from "./en_metrics";
|
||||
import { EN_STORAGE } from "./en_storage";
|
||||
import { EN_USAGE } from "./en_usage";
|
||||
@@ -37,6 +38,7 @@ export const EN: Record<string, string> = {
|
||||
"工作台": "Workbench",
|
||||
"从右侧选择文件,或打开浏览器": "Choose a file on the right, or open a browser",
|
||||
...EN_SETTINGS,
|
||||
...EN_PERSONA,
|
||||
...EN_COMMUNITY,
|
||||
...EN_SPONSOR,
|
||||
...EN_METRICS,
|
||||
|
||||
@@ -556,6 +556,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"等待时长超出允许范围,未做任何修改:{detail}": "That wait time is out of range; nothing was changed: {detail}",
|
||||
"自动归档设置未能保存:{detail}": "The auto-archive settings were not saved: {detail}",
|
||||
"天数超出允许范围,未做任何修改:{detail}": "The number of days is out of range; nothing was changed: {detail}",
|
||||
"人设内容超出长度上限,未做任何修改:{detail}": "The persona text is over the length limit; nothing was changed: {detail}",
|
||||
"交付验收卡片的设置未能保存:{detail}": "The receipt card setting was not saved: {detail}",
|
||||
"主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹": "A home directory or drive root cannot be trusted as a whole - open a specific project folder",
|
||||
"未能记下对此文件夹的信任决定:{detail}": "The trust decision for this folder was not saved: {detail}",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export const EN_PERSONA: Record<string, string> = {
|
||||
"人设": "Persona",
|
||||
"系统人设": "System persona",
|
||||
"已保存 · 下次会话构建时生效": "Saved · applies at the next session build",
|
||||
"下次会话构建时生效": "Applies at the next session build",
|
||||
"系统人设决定 agent 的身份:前缀写在系统提示词最前面,后缀写在内置指令之后。保存不会立即重建会话;新会话及现有会话下一次重建(例如切换模型)时生效。":
|
||||
"The system persona shapes the agent's identity: the prefix leads the system prompt and the suffix follows the built-in guidance. Saving does not rebuild a session immediately. Changes apply to new sessions and the next rebuild of an existing session, such as a model switch.",
|
||||
"无法读取人设设置。": "Couldn't read the persona settings.",
|
||||
"预设": "Presets",
|
||||
"严谨工程师": "Rigorous engineer",
|
||||
"简洁": "Concise",
|
||||
"教学导师": "Mentor",
|
||||
"严苛评审": "Critic",
|
||||
"先验证再下结论,只做最少的正确改动,并说明剩余风险。": "Verify before concluding, make the smallest correct change, and state the residual risk.",
|
||||
"回答简短直接,不铺垫,优先代码与要点。": "Answer briefly and directly, skip the preamble, prefer code and bullets.",
|
||||
"像老师一样说明取舍与原理,把决定权留给你。": "Explain trade-offs and reasoning like a teacher, and leave the decision to you.",
|
||||
"以审查者的立场找问题:先指出失效方式和证据,再谈通过。": "Review adversarially: name the failure mode and the evidence before approving.",
|
||||
"未应用预设;直接填写下面的两段即可。": "No preset applied; fill in the two boxes below.",
|
||||
"已修改预设内容,按下面的字段保存。": "The preset text has been edited; save the fields below.",
|
||||
"系统提示词开头": "Start of the system prompt",
|
||||
"作为系统提示词的第一段,写在内置指令之前;留空表示不添加。": "Leads the system prompt, ahead of the built-in guidance; leave it empty to add nothing.",
|
||||
"第一方指令之后": "After the first-party guidance",
|
||||
"紧跟在内置指令之后、运行时上下文之前;留空表示不添加。": "Follows the built-in guidance and precedes the runtime context; leave it empty to add nothing.",
|
||||
"不注入可选运行时上下文": "Do not inject optional runtime context",
|
||||
"关闭后不注入探测到的运行环境与离线提示;指令、记忆与技能照常写入。": "When on, the probed environment and the offline note are not injected; instructions, memory and skills are written as usual.",
|
||||
"写入用户配置,在新会话及现有会话下一次重建时生效;保存本身不会重建会话。": "Writes the user config; applies to new sessions and the next rebuild of an existing session. Saving itself does not rebuild it.",
|
||||
"已保存,下次会话构建时生效": "Saved; applies at the next session build",
|
||||
};
|
||||
@@ -493,6 +493,7 @@ const SAID: Record<string, string> = {
|
||||
"progress_watch.save_failed": "无进展设置未能保存:{detail}",
|
||||
"auto_archive.save_failed": "自动归档设置未能保存:{detail}",
|
||||
"auto_archive.out_of_range": "天数超出允许范围,未做任何修改:{detail}",
|
||||
"persona.too_large": "人设内容超出长度上限,未做任何修改:{detail}",
|
||||
"workspace.untrustable": "主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹",
|
||||
"workspace.trust_save_failed": "未能记下对此文件夹的信任决定:{detail}",
|
||||
"progress_watch.out_of_range": "该数值超出允许范围,未做任何修改:{detail}",
|
||||
|
||||
@@ -201,6 +201,26 @@ export interface AutoArchiveSettings {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// The user's system persona. The kernel serves the built-in starting points
|
||||
// with the settings so the page never keeps a second copy of the prompt text.
|
||||
export interface PersonaPreset {
|
||||
id: string;
|
||||
prefix: string;
|
||||
suffix: string;
|
||||
include_runtime_context: boolean;
|
||||
}
|
||||
|
||||
export interface PersonaSettings {
|
||||
prefix: string;
|
||||
suffix: string;
|
||||
include_runtime_context: boolean;
|
||||
path: string;
|
||||
presets: PersonaPreset[];
|
||||
}
|
||||
|
||||
/** The three fields a save writes; path and presets are the kernel's answer. */
|
||||
export type PersonaDraft = Pick<PersonaSettings, "prefix" | "suffix" | "include_runtime_context">;
|
||||
|
||||
export type DisplayCurrencyMode = "auto" | "CNY" | "USD";
|
||||
|
||||
// Which currency costs are shown in; the user file holds it.
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, Write
|
||||
import type { AskTimeoutSettings, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, WebAccessPatch, WebAccessSettings, SearxngProbe, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import { HttpError } from "./http_error";
|
||||
import { MockShell } from "./mock_shell";
|
||||
import type { PersonaDraft, PersonaSettings } from "./boundary";
|
||||
|
||||
// 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
|
||||
@@ -217,6 +218,32 @@ export class MockBoundary extends MockShell {
|
||||
this.archive = { ...this.archive, ...s };
|
||||
return { ...this.archive };
|
||||
}
|
||||
private personaState: PersonaSettings = {
|
||||
prefix: "", suffix: "", include_runtime_context: true,
|
||||
path: "/Users/you/.reasonix/config.toml",
|
||||
presets: [
|
||||
{
|
||||
id: "engineer",
|
||||
prefix: "You are a rigorous staff engineer. Verify before claiming, make the smallest correct change, and state the residual risk.",
|
||||
suffix: "", include_runtime_context: true,
|
||||
},
|
||||
{
|
||||
id: "concise",
|
||||
prefix: "",
|
||||
suffix: "Be terse. Skip the preamble; prefer code and bullets.",
|
||||
include_runtime_context: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async persona(): Promise<PersonaSettings> {
|
||||
return { ...this.personaState, presets: this.personaState.presets.map((p) => ({ ...p })) };
|
||||
}
|
||||
|
||||
async savePersonaSettings(s: PersonaDraft): Promise<PersonaSettings> {
|
||||
this.personaState = { ...this.personaState, ...s };
|
||||
return this.persona();
|
||||
}
|
||||
|
||||
private currency: DisplayCurrencySettings = { mode: "auto", path: "/Users/you/.reasonix/config.toml", unconverted: [] };
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ export class MockHub implements HubPort {
|
||||
name: root.split("/").pop() ?? root,
|
||||
open: this.views.some((v) => v.root === root),
|
||||
remembered: true,
|
||||
sessions: this.placed(root,
|
||||
sessions: this.placed(root,
|
||||
i === 0
|
||||
? [
|
||||
{ path: "/sessions/mock.jsonl", name: "mock", title: "并行会话演示", turns: 3, runtimeId: open.get("/sessions/mock.jsonl") },
|
||||
|
||||
@@ -52,6 +52,7 @@ import type { Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, Provid
|
||||
export type { ModelEffort, ModelLimit, ModelRate, Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
|
||||
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
|
||||
import type { FeedbackEnv, FeedbackImage, FeedbackLinkedInstall, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
|
||||
import type { PersonaDraft, PersonaSettings } from "./boundary";
|
||||
|
||||
export type * from "./plugin";
|
||||
export type * from "./market";
|
||||
@@ -314,6 +315,10 @@ export interface AgentPort {
|
||||
autoArchive(): Promise<AutoArchiveSettings>;
|
||||
// Read by the kernel's background sweep on each pass; no rebuild.
|
||||
saveAutoArchive(s: Pick<AutoArchiveSettings, "enabled" | "days">): Promise<AutoArchiveSettings>;
|
||||
persona(): Promise<PersonaSettings>;
|
||||
// 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>;
|
||||
receiptSettings(): Promise<ReceiptSettings>;
|
||||
saveReceiptSettings(show: boolean): Promise<ReceiptSettings>;
|
||||
// null in a browser tab: there is no window to keep running.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SseShell } from "./sse_shell";
|
||||
import type { PersonaDraft, PersonaSettings } from "./boundary";
|
||||
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
|
||||
import type { AskTimeoutSettings, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, WebAccessPatch, WebAccessSettings, SearxngProbe, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
|
||||
@@ -119,6 +120,12 @@ export class SseBoundary extends SseShell {
|
||||
saveAutoArchive(s: Pick<AutoArchiveSettings, "enabled" | "days">) {
|
||||
return this.post0<AutoArchiveSettings>("/auto-archive", s);
|
||||
}
|
||||
persona() {
|
||||
return this.get<PersonaSettings>("/persona");
|
||||
}
|
||||
savePersonaSettings(s: PersonaDraft) {
|
||||
return this.post0<PersonaSettings>("/persona", s);
|
||||
}
|
||||
askTimeout() {
|
||||
return this.get<AskTimeoutSettings>("/ask-timeout");
|
||||
}
|
||||
|
||||
@@ -3681,6 +3681,21 @@
|
||||
.lrow .sa + .sc { margin-left: 8px }
|
||||
.lrow .sc { margin-left: auto; flex: none; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint) }
|
||||
|
||||
/* The persona prompt boxes are prompt-sized rather than row inputs: full
|
||||
width, monospace because the content is prompt text, and draggable because
|
||||
a persona can be long. */
|
||||
.pers-field { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px 0 }
|
||||
.pers-field .lb { font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--text) }
|
||||
.pers-field .ds { font: var(--w-reg) var(--fs-meta)/1.55 var(--ui); color: var(--faint) }
|
||||
.pers-field textarea {
|
||||
min-height: 84px; padding: 7px 9px; border: 1px solid var(--border); border-radius: var(--r-sm);
|
||||
background: var(--raised); color: var(--text); outline: none; resize: vertical;
|
||||
font: var(--w-reg) var(--fs-ui)/1.6 var(--mono);
|
||||
}
|
||||
.pers-field textarea:focus-visible { border-color: var(--focus); box-shadow: 0 0 0 2px color-mix(in oklab, var(--focus) 26%, transparent) }
|
||||
.pers-field textarea:disabled { opacity: .6 }
|
||||
.box .persona-name { margin: 10px 0 0; font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); color: var(--muted) }
|
||||
|
||||
.skrow { display: flex; align-items: baseline; gap: 10px; padding: 8px 12px; border-top: 1px solid var(--hair) }
|
||||
.skrow:last-of-type { border-bottom: 1px solid var(--hair) }
|
||||
.skrow[data-off] .nm, .skrow[data-off] .ds { color: var(--ghost) }
|
||||
|
||||
@@ -1364,7 +1364,7 @@ body { background: var(--page) !important; }
|
||||
.workbench-canvas .bbar > .vtabs { display: none; }
|
||||
.workbench-explorer { min-width: 0; display: flex; flex-direction: column; border-left: 1px solid var(--hair); background: var(--page); }
|
||||
.workbench-explorer-head { height: 36px; padding: 0 12px; flex: none; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--hair); color: var(--muted); font: 620 calc(12px * var(--fs-scale, 1))/1 var(--ui); text-transform: uppercase; }
|
||||
.workbench-explorer-head small { color: var(--faint); }
|
||||
.workbench-explorer-head small { color: var(--faint); font-size: calc(10px * var(--fs-scale, 1)); }
|
||||
.workbench-explorer-head small { margin-left: auto; }
|
||||
.workbench-open-editor {
|
||||
width: 28px; height: 28px; margin-left: 8px; padding: 0; flex: none;
|
||||
|
||||
@@ -62,10 +62,11 @@ export const SCOPE_SAID: Record<SettingScope, string> = {
|
||||
|
||||
// 「已保存」和「生效」是两件事,重启那一档必须把它们分开说 —— 否则关掉设置的人
|
||||
// 不知道刚填的东西还在不在。
|
||||
const APPLY_SAID: Record<"immediate" | "runtime-rebuild" | "restart" | "on-open" | "next-turn", string> = {
|
||||
const APPLY_SAID: Record<"immediate" | "runtime-rebuild" | "restart" | "on-open" | "next-turn" | "next-rebuild", string> = {
|
||||
immediate: "立即生效",
|
||||
"runtime-rebuild": "重建运行时",
|
||||
restart: "已保存 · 重启后生效",
|
||||
"on-open": "已保存 · 新开的会话生效",
|
||||
"next-turn": "已保存 · 下个回合起生效",
|
||||
"next-rebuild": "已保存 · 下次会话构建时生效",
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } 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: "", include_runtime_context: true,
|
||||
path: "/u/config.toml",
|
||||
presets: [
|
||||
{ id: "engineer", prefix: "Rigorous engineer prefix.", suffix: "", include_runtime_context: true },
|
||||
{ id: "concise", prefix: "", suffix: "Be terse.", 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.queryByRole("switch", { name: /complete/ })).toBeNull();
|
||||
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 supported fields and declares the next session build", 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.",
|
||||
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("shows the kernel's size refusal", async () => {
|
||||
const port = portWith();
|
||||
render(<Persona port={port} />);
|
||||
await userEvent.type(await field("系统提示词开头"), "Something.");
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
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: "", include_runtime_context: true };
|
||||
|
||||
const draftOf = (s: PersonaSettings): PersonaDraft => ({
|
||||
prefix: s.prefix,
|
||||
suffix: s.suffix,
|
||||
include_runtime_context: s.include_runtime_context,
|
||||
});
|
||||
|
||||
const sameDraft = (a: PersonaDraft, b: PersonaDraft) =>
|
||||
a.prefix === b.prefix && a.suffix === b.suffix &&
|
||||
a.include_runtime_context === b.include_runtime_context;
|
||||
|
||||
const isPreset = (p: PersonaPreset, d: PersonaDraft) =>
|
||||
sameDraft({ prefix: p.prefix, suffix: p.suffix, 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 "";
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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,
|
||||
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("不注入可选运行时上下文")}</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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useModalFocus } from "./useModalFocus";
|
||||
import { t } from "../i18n";
|
||||
import { listenAction } from "./listen";
|
||||
import { useRuntimeReload } from "./RuntimeReload";
|
||||
import type { AccountState, AgentPort, Appearance as Look, CapabilityScope, McpEntry, ModelEntry, PluginPackage, SessionStatus, SkillEntry } from "../port/port";
|
||||
import { arrowTabs } from "./tablist";
|
||||
import { useSettingsSearch } from "./useSettingsSearch";
|
||||
import { bytes, tokens as fmtTokens } from "../i18n/format";
|
||||
import { ICON, NAV, SECTION_NAME } from "./prefsnav";
|
||||
import { UpdateDot } from "./UpdateDot";
|
||||
import { SECTION_NAME } from "./prefsnav";
|
||||
import { SettingsNav } from "./SettingsNav";
|
||||
import { Group, SCOPE_SAID } from "./Group";
|
||||
import { PlanApproval } from "./PlanApproval";
|
||||
import { SessionCoordination } from "./SessionCoordination";
|
||||
@@ -39,6 +38,7 @@ import { WebAccess } from "./WebAccess";
|
||||
import { ProgressWatch } from "./ProgressWatch";
|
||||
import { DisplaySettings } from "./DisplaySettings";
|
||||
import { AutoArchive } from "./AutoArchive";
|
||||
import { Persona } from "./Persona";
|
||||
import { Account } from "./Account";
|
||||
import { Backup } from "./Backup";
|
||||
import { BackupFile } from "./BackupFile";
|
||||
@@ -295,6 +295,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
// each section rides on its own row, so the risky one is legible from here.
|
||||
const nav: Record<Section, string> = {
|
||||
session: status?.plan ? t("计划模式") : preset,
|
||||
persona: "",
|
||||
model: status?.modelRef?.split("/").pop() ?? "—",
|
||||
providers: models.length ? t("{n} 个服务", { n: groupVendors(models).length }) : "",
|
||||
tools: ruleCount ? `${approval} · ${ruleCount}` : approval,
|
||||
@@ -417,7 +418,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
<span className="cost">
|
||||
{[
|
||||
t(SCOPE_SAID[e.scope]),
|
||||
e.apply === "runtime-rebuild" ? t("重建运行时") : e.apply === "restart" ? t("重启后生效") : e.apply === "on-open" ? t("新开的会话生效") : e.apply === "next-turn" ? t("下个回合起生效") : "",
|
||||
e.apply === "runtime-rebuild" ? t("重建运行时") : e.apply === "restart" ? t("重启后生效") : e.apply === "on-open" ? t("新开的会话生效") : e.apply === "next-turn" ? t("下个回合起生效") : e.apply === "next-rebuild" ? t("下次会话构建时生效") : "",
|
||||
].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</button>
|
||||
@@ -428,34 +429,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
</div>
|
||||
<ConfigTrouble port={port} onRepaired={onChanged} />
|
||||
<div className="prefs-body">
|
||||
<nav className="prefs-nav" role="tablist" aria-label={t("设置分类")} onKeyDown={arrowTabs}>
|
||||
{NAV.map(([group, items]) => {
|
||||
const rows = items.filter(([id]) => shown(id));
|
||||
// A heading over nothing is worse than no heading: remote is absent
|
||||
// on a kernel without it, advanced until something moves in.
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<Fragment key={group}>
|
||||
<div className="navsec">{t(group)}</div>
|
||||
{/* One tab stop for the list, arrows move inside it: twelve
|
||||
stops made Tab the wrong way through this screen. */}
|
||||
{rows.map(([id, name]) => (
|
||||
<button key={id} id={`prefs-${id}`} role="tab" title={t(name)}
|
||||
data-action="settings.section" data-value={id}
|
||||
tabIndex={at === id ? 0 : -1}
|
||||
aria-selected={at === id} disabled={updatingPkg.applying} onClick={() => setAt(id)}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">{ICON[id]}</svg>
|
||||
{id === "versions" && <UpdateDot />}
|
||||
<span className="nm">{t(name)}</span>
|
||||
<span className="nv" title={nav[id] || undefined} data-danger={danger(id) ? "" : undefined}>
|
||||
{nav[id]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<SettingsNav at={at} values={nav} shown={shown} danger={danger} disabled={updatingPkg.applying} onSelect={setAt} />
|
||||
|
||||
<div className="prefs-main" role="tabpanel" aria-labelledby={`prefs-${at}`} data-sec={at}>
|
||||
<Boundary
|
||||
@@ -534,6 +508,8 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
</>
|
||||
)}
|
||||
|
||||
{at === "persona" && <Persona port={port} />}
|
||||
|
||||
{at === "providers" && (
|
||||
<Group id="providers" title={t("模型服务")}
|
||||
hint={t("选择一个服务查看和修改;地址、密钥与模型列表保存后生效。自定义中转站的协议与模型会在连接后自动探测。")}>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Fragment } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { ICON, NAV, type Section } from "./prefsnav";
|
||||
import { arrowTabs } from "./tablist";
|
||||
import { UpdateDot } from "./UpdateDot";
|
||||
|
||||
interface Props {
|
||||
at: Section;
|
||||
values: Record<Section, string>;
|
||||
shown: (id: Section) => boolean;
|
||||
danger: (id: Section) => boolean;
|
||||
disabled: boolean;
|
||||
onSelect: (id: Section) => void;
|
||||
}
|
||||
|
||||
export function SettingsNav({ at, values, shown, danger, disabled, onSelect }: Props) {
|
||||
return (
|
||||
<nav className="prefs-nav" role="tablist" aria-label={t("设置分类")} onKeyDown={arrowTabs}>
|
||||
{NAV.map(([group, items]) => {
|
||||
const rows = items.filter(([id]) => shown(id));
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<Fragment key={group}>
|
||||
<div className="navsec">{t(group)}</div>
|
||||
{rows.map(([id, name]) => (
|
||||
<button key={id} id={`prefs-${id}`} role="tab" title={t(name)}
|
||||
data-action="settings.section" data-value={id}
|
||||
tabIndex={at === id ? 0 : -1}
|
||||
aria-selected={at === id} disabled={disabled} onClick={() => onSelect(id)}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">{ICON[id]}</svg>
|
||||
{id === "versions" && <UpdateDot />}
|
||||
<span className="nm">{t(name)}</span>
|
||||
<span className="nv" title={values[id] || undefined} data-danger={danger(id) ? "" : undefined}>
|
||||
{values[id]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -270,6 +270,13 @@ describe("a background job notice", () => {
|
||||
});
|
||||
|
||||
describe("a notice about a setting the model named", () => {
|
||||
it.each(["agent.persona.prefix", "agent.persona.suffix", "agent.persona.include_runtime_context"])("opens the user persona editor for %s", (id) => {
|
||||
const open = vi.fn();
|
||||
const box = render(<NoticeCard item={notice({ level: "info", code: "setting_open", text: "Open the persona editor", detail: id })} onSettings={open} />).container;
|
||||
fireEvent.click(box.querySelector("button")!);
|
||||
expect(open).toHaveBeenCalledExactlyOnceWith("persona:persona");
|
||||
});
|
||||
|
||||
it("offers to open the setting where it is drawn", () => {
|
||||
const open = vi.fn();
|
||||
const box = render(<NoticeCard item={notice({ level: "info", code: "setting_open", text: "助手需要改动 permissions.mode", detail: "permissions.mode" })} onSettings={open} />).container;
|
||||
|
||||
@@ -4,13 +4,21 @@ import { t } from "../i18n";
|
||||
/** The settings table of contents: which sections exist, what each is called,
|
||||
* what mark it carries and which question it answers. Kept out of Settings
|
||||
* itself because it is a table, not a screen — the screen reads it. */
|
||||
export type Section = "session" | "model" | "providers" | "tools" | "hooks" | "ext" | "network" | "remote" | "memory" | "usage" | "storage" | "account" | "versions" | "appearance" | "advanced";
|
||||
export type Section = "session" | "persona" | "model" | "providers" | "tools" | "hooks" | "ext" | "network" | "remote" | "memory" | "usage" | "storage" | "account" | "versions" | "appearance" | "advanced";
|
||||
|
||||
// Drawn on the same 16-unit grid at 1.45 stroke as the rest of this screen's
|
||||
// marks. The rail in Nav.tsx draws a section's entry from this table, so one
|
||||
// destination carries one mark wherever it is shown.
|
||||
export const ICON: Record<Section, ReactNode> = {
|
||||
session: <path d="M2.6 8h10.8M8 2.6v10.8" />,
|
||||
// An identity card: the section is about who the agent is, not what it does.
|
||||
persona: (
|
||||
<>
|
||||
<rect x="2.2" y="3.4" width="11.6" height="9.2" rx="1.4" />
|
||||
<circle cx="5.8" cy="7.2" r="1.3" />
|
||||
<path d="M8.6 6.2h3.2M8.6 8.4h3.2M3.9 11.4c.5-1.1 1.5-1.7 2.7-1.7s2.2.6 2.7 1.7" />
|
||||
</>
|
||||
),
|
||||
model: (
|
||||
<>
|
||||
<circle cx="8" cy="8" r="2.4" />
|
||||
@@ -90,6 +98,7 @@ export const NAV: [string, [Section, string][]][] = [
|
||||
"本轮执行",
|
||||
[
|
||||
["session", "会话"],
|
||||
["persona", "人设"],
|
||||
["model", "模型偏好"],
|
||||
["providers", "模型服务"],
|
||||
["tools", "工具与权限"],
|
||||
@@ -144,8 +153,9 @@ export const NAV: [string, [Section, string][]][] = [
|
||||
* next-turn written and kept now; the running session takes it at its
|
||||
* next turn boundary, because it changes what the model is
|
||||
* given (the tool table), which only moves between turns.
|
||||
* next-rebuild saved without rebuilding now; the next runtime build reads it.
|
||||
* none nothing here changes anything: the block only reports. */
|
||||
export type ApplySemantics = "immediate" | "runtime-rebuild" | "restart" | "on-open" | "next-turn" | "none";
|
||||
export type ApplySemantics = "immediate" | "runtime-rebuild" | "restart" | "on-open" | "next-turn" | "next-rebuild" | "none";
|
||||
|
||||
/** Who owns what a block governs — the other half of the contract, and the one
|
||||
* the screen could not answer. "When does it take effect" was declared here
|
||||
@@ -207,6 +217,8 @@ export const SETTINGS: SettingEntry[] = [
|
||||
{ section: "session", anchor: "fold-work", title: "折叠中间过程", scope: "machine", apply: "immediate", keywords: ["折叠", "中间过程", "工具调用", "思考", "最后一句", "收起", "摘要"] },
|
||||
// Written to the user file only; the kernel's background sweep reads it on each pass.
|
||||
{ section: "session", anchor: "auto-archive", title: "自动归档不活跃的对话", scope: "machine", apply: "immediate", keywords: ["归档", "过期", "清理", "闲置", "天数"] },
|
||||
// Saving does not rebuild; any later runtime build reads the user persona.
|
||||
{ section: "persona", anchor: "persona", title: "系统人设", scope: "machine", apply: "next-rebuild", keywords: ["人设", "persona", "身份", "提示词", "系统提示词", "前缀", "后缀", "预设"] },
|
||||
{ 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,6 +4,9 @@ import { SETTINGS } from "./prefsnav";
|
||||
// sheet opens on and highlights. A setting with no row here opens the sheet at
|
||||
// its top; the kernel's own declaration says which ids exist.
|
||||
export const SETTING_SCREEN: Record<string, string> = {
|
||||
"agent.persona.prefix": "persona:persona",
|
||||
"agent.persona.suffix": "persona:persona",
|
||||
"agent.persona.include_runtime_context": "persona:persona",
|
||||
"peers.enabled": "tools:peers",
|
||||
"peers.dispatch": "tools:peers",
|
||||
"peers.release_projects": "tools:peers",
|
||||
|
||||
@@ -22,7 +22,7 @@ const ROUTES = [
|
||||
"/changes", "/commit", "/attachments", "/drop", "/checkpoints", "/branches", "/compact", "/compaction", "/rewind", "/branch",
|
||||
"/extensions", "/themes", "/plugins", "/market", "/surfaces", "/feedback",
|
||||
"/fork", "/summarize", "/forget", "/bypass", "/auto-approve-tools",
|
||||
"/permissions", "/sandbox", "/progress-watch", "/ask-timeout", "/session-ref-settings", "/changed-files-settings", "/fold-work-settings", "/turn-narration-settings", "/display-currency", "/auto-archive", "/context", "/storage", "/tray", "/browser", "/browser-host", "/asks", "/update", "/receipt-settings", "/subagent-claims", "/write-lease", "/peers",
|
||||
"/permissions", "/sandbox", "/progress-watch", "/ask-timeout", "/session-ref-settings", "/changed-files-settings", "/fold-work-settings", "/turn-narration-settings", "/display-currency", "/auto-archive", "/persona", "/context", "/storage", "/tray", "/browser", "/browser-host", "/asks", "/update", "/receipt-settings", "/subagent-claims", "/write-lease", "/peers",
|
||||
"/host", "/notifications", "/share", "/pair", "/device",
|
||||
"/web-access-settings", "/slash", "/workspaces", "/welcome", "/usage", "/config", "/studio",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
owner: @SivanCola
|
||||
backup: @esengine
|
||||
status: active
|
||||
reviewed: 2026-10-18
|
||||
---
|
||||
|
||||
# System persona / 系统人设
|
||||
|
||||
The user configuration may declare `[agent.persona]` with `prefix`, `suffix`
|
||||
and `include_runtime_context`. Prefix and suffix supplement first-party guidance;
|
||||
they cannot replace it. The retired `complete` field has no execution effect.
|
||||
An absent persona preserves the existing default prompt.
|
||||
|
||||
用户配置可通过 `[agent.persona]` 设置 `prefix`、`suffix` 和
|
||||
`include_runtime_context`。前后缀补充内置指令,不能替换它;旧 `complete`
|
||||
字段不再影响执行。未配置人设时保持原有默认提示词。
|
||||
|
||||
Project `reasonix.toml` cannot set these fields. Loading restores the user's
|
||||
values and reports an ignored user-only setting. This applies even when the
|
||||
user has no persona configured.
|
||||
|
||||
项目 `reasonix.toml` 不能设置这些字段。加载时恢复用户配置,并报告忽略了项目中
|
||||
的用户级设置;用户未配置人设时也同样如此。
|
||||
|
||||
`GET /persona` returns the user values and built-in starting points.
|
||||
`POST /persona` saves `prefix`, `suffix` and optional `include_runtime_context`.
|
||||
The text is trimmed and limited to 32 KiB per field, 64 KiB combined.
|
||||
|
||||
Saving does not rebuild a running session. New sessions and the next rebuild
|
||||
of an existing session (for example, a model switch) read the saved persona.
|
||||
|
||||
`GET /persona` 返回用户设置和内置起始模板。`POST /persona` 保存 `prefix`、
|
||||
`suffix` 及可选的 `include_runtime_context`。文本会去除首尾空白,每段最多
|
||||
32 KiB,总计最多 64 KiB。保存本身不重建会话;新会话及现有会话下一次重建
|
||||
(例如切换模型)时读取已保存的人设。
|
||||
@@ -0,0 +1,172 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
// personaConfigTemplate is the smallest workspace a Build needs, with %s taking
|
||||
// an extra block placed after the [agent] scalars: a [agent.persona] table, or
|
||||
// an [environment] table for the runtime-context tests.
|
||||
const personaConfigTemplate = `
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE SYSTEM PROMPT"
|
||||
%s
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "openai"
|
||||
base_url = "https://example.invalid"
|
||||
model = "x"
|
||||
api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
`
|
||||
|
||||
// buildPersonaPrompt builds one runtime over a fresh workspace with the given
|
||||
// extra config block and returns the composed system prompt.
|
||||
func buildPersonaPrompt(t *testing.T, extra string) string {
|
||||
t.Helper()
|
||||
home := isolateConfigHome(t)
|
||||
t.Setenv("REASONIX_HOME", filepath.Join(home, ".reasonix"))
|
||||
t.Setenv("REASONIX_STATE_HOME", "")
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
writeFile(t, os.Getenv("REASONIX_HOME"), "config.toml", fmt.Sprintf(personaConfigTemplate, extra))
|
||||
approveWorkspace(t, dir)
|
||||
b, err := Build(context.Background(), Options{Home: os.Getenv("REASONIX_HOME"), WorkspaceRoot: dir, resolvedShell: pinnedEffectShell()})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
prompt := systemMessage(b.History())
|
||||
b.Close()
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
t.Fatal("Build composed an empty system prompt")
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
|
||||
func lastPolicyIndex(prompt string) int {
|
||||
return strings.LastIndex(prompt, config.ExternalContentPolicy)
|
||||
}
|
||||
|
||||
func TestPersonaPrefixLeadsAndSuffixFollowsPolicies(t *testing.T) {
|
||||
prompt := buildPersonaPrompt(t, `
|
||||
[agent.persona]
|
||||
prefix = "PERSONA IDENTITY"
|
||||
suffix = "PERSONA SUFFIX"
|
||||
`)
|
||||
if !strings.HasPrefix(prompt, "PERSONA IDENTITY") {
|
||||
t.Fatalf("the persona prefix does not lead the prompt: %q", prompt[:min(len(prompt), 120)])
|
||||
}
|
||||
base := strings.Index(prompt, "BASE SYSTEM PROMPT")
|
||||
prefix := strings.Index(prompt, "PERSONA IDENTITY")
|
||||
suffix := strings.Index(prompt, "PERSONA SUFFIX")
|
||||
if !(prefix < base && base < suffix) {
|
||||
t.Fatalf("order prefix/base/suffix = %d/%d/%d, want prefix before base before suffix", prefix, base, suffix)
|
||||
}
|
||||
if last := lastPolicyIndex(prompt); suffix < last {
|
||||
t.Fatalf("persona suffix at %d is before the last core policy at %d", suffix, last)
|
||||
}
|
||||
if env := strings.Index(prompt, "## Environment"); env >= 0 && suffix > env {
|
||||
t.Fatalf("persona suffix at %d follows the environment section at %d", suffix, env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeprecatedCompleteCannotReplaceFirstPartyPrompt(t *testing.T) {
|
||||
prompt := buildPersonaPrompt(t, `
|
||||
[agent.persona]
|
||||
prefix = "ONLY THIS PERSONA"
|
||||
suffix = "USER TAIL"
|
||||
complete = true
|
||||
`)
|
||||
if !strings.HasPrefix(prompt, "ONLY THIS PERSONA") || !strings.Contains(prompt, "USER TAIL") || !strings.Contains(prompt, config.WorkPracticePolicy) || !strings.Contains(prompt, "BASE SYSTEM PROMPT") {
|
||||
t.Fatalf("deprecated complete erased first-party guidance: %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonaRuntimeContextSwitch(t *testing.T) {
|
||||
const offline = "\n[environment]\noffline = true\n"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
persona string
|
||||
wantEnv bool
|
||||
wantNote bool
|
||||
}{
|
||||
{
|
||||
name: "default includes runtime context",
|
||||
persona: `
|
||||
[agent.persona]
|
||||
prefix = "IDENTITY"
|
||||
`,
|
||||
wantEnv: true, wantNote: true,
|
||||
},
|
||||
{
|
||||
name: "false drops the environment section and offline note",
|
||||
persona: `
|
||||
[agent.persona]
|
||||
prefix = "IDENTITY"
|
||||
suffix = "SUFFIX"
|
||||
include_runtime_context = false
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "the switch alone is enough",
|
||||
persona: `
|
||||
[agent.persona]
|
||||
include_runtime_context = false
|
||||
`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
prompt := buildPersonaPrompt(t, tc.persona+offline)
|
||||
if got := strings.Contains(prompt, "## Environment"); got != tc.wantEnv {
|
||||
t.Fatalf("environment section present = %v, want %v:\n%s", got, tc.wantEnv, prompt)
|
||||
}
|
||||
if got := strings.Contains(prompt, config.OfflineEnvironmentNote); got != tc.wantNote {
|
||||
t.Fatalf("offline note present = %v, want %v", got, tc.wantNote)
|
||||
}
|
||||
// Instructions and memory/skill guidance are never optional context.
|
||||
if !strings.Contains(prompt, "BASE SYSTEM PROMPT") || !strings.Contains(prompt, config.WorkPracticePolicy) {
|
||||
t.Fatalf("runtime-context suppression removed instructions or policies:\n%s", prompt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An empty persona must not move a single byte: that is the compatibility
|
||||
// promise for every config written before this feature existed.
|
||||
func TestEmptyPersonaLeavesThePromptUnchanged(t *testing.T) {
|
||||
home := isolateConfigHome(t)
|
||||
t.Setenv("REASONIX_HOME", filepath.Join(home, ".reasonix"))
|
||||
t.Setenv("REASONIX_STATE_HOME", "")
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
approveWorkspace(t, dir)
|
||||
opts := Options{Home: os.Getenv("REASONIX_HOME"), WorkspaceRoot: dir, resolvedShell: pinnedEffectShell()}
|
||||
build := func(extra string) string {
|
||||
writeFile(t, os.Getenv("REASONIX_HOME"), "config.toml", fmt.Sprintf(personaConfigTemplate, extra))
|
||||
b, err := Build(context.Background(), opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
prompt := systemMessage(b.History())
|
||||
b.Close()
|
||||
return prompt
|
||||
}
|
||||
without := build("")
|
||||
for name, extra := range map[string]string{
|
||||
"empty table": "\n[agent.persona]\n",
|
||||
"blank bodies": "\n[agent.persona]\nprefix = \" \"\nsuffix = \"\"\n",
|
||||
} {
|
||||
if got := build(extra); got != without {
|
||||
t.Fatalf("%s changed the prompt:\nwithout: %q\nwith: %q", name, without, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
func TestEffectSettingToolCannotChangeOrDiscloseUserPersona(t *testing.T) {
|
||||
for _, tc := range []struct{ id, value string }{
|
||||
{"agent.persona.prefix", `"replacement prefix"`},
|
||||
{"agent.persona.suffix", `"replacement suffix"`},
|
||||
{"agent.persona.include_runtime_context", `false`},
|
||||
} {
|
||||
t.Run(tc.id, func(t *testing.T) {
|
||||
rec, ctrl, userFile, approvals := settingSession(t, tc.id, tc.value)
|
||||
include := true
|
||||
before := config.PersonaConfig{Prefix: "private user prefix", Suffix: "private user suffix", IncludeRuntimeContext: &include}
|
||||
cfg := config.LoadForEdit(userFile)
|
||||
cfg.Agent.Persona = before
|
||||
if err := cfg.SaveTo(userFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-runSetting(ctrl); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
select {
|
||||
case card := <-approvals:
|
||||
t.Fatalf("persona proposal offered an approval card: %+v", card)
|
||||
default:
|
||||
}
|
||||
result := rec.toolResult()
|
||||
for _, want := range []string{"setting.guarded", "(fix: setting op=open id=" + tc.id + ")"} {
|
||||
if !strings.Contains(result, want) {
|
||||
t.Fatalf("tool result = %q, want %q", result, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(result, before.Prefix) || strings.Contains(result, before.Suffix) {
|
||||
t.Fatal("persona refusal disclosed the user's value")
|
||||
}
|
||||
if got := config.LoadForEdit(userFile).Agent.Persona; !reflect.DeepEqual(got, before) {
|
||||
t.Fatalf("guarded persona changed: %+v", got)
|
||||
}
|
||||
for _, row := range ctrl.SettingsList("agent.persona") {
|
||||
if strings.HasPrefix(row.ID, "agent.persona.") && (row.Value != nil || row.Class != "guarded") {
|
||||
t.Fatalf("persona list disclosed a boundary value: %+v", row)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"reasonix/internal/ext/skill"
|
||||
"reasonix/internal/platform/environment"
|
||||
"reasonix/internal/runtime/outputstyle"
|
||||
"reasonix/internal/runtime/persona"
|
||||
"reasonix/internal/safety/sandbox"
|
||||
"reasonix/internal/state/instruction"
|
||||
"reasonix/internal/state/memory"
|
||||
@@ -36,9 +37,10 @@ type promptAssembly struct {
|
||||
}
|
||||
|
||||
// buildPromptAssembly resolves the base system prompt and folds in, in order,
|
||||
// the output style, core policies, workspace line, environment section, offline
|
||||
// note, memory, and the skill index. Order is the contract: it decides the
|
||||
// prefix bytes every turn reuses.
|
||||
// the persona prefix, output style, core policies, persona suffix, environment
|
||||
// section, offline note, memory, and the skill index.
|
||||
// Order is the contract: it decides the cache-stable prefix bytes every turn
|
||||
// reuses. Saving does not rebuild a runtime; the next build reads saved values.
|
||||
func buildPromptAssembly(ctx context.Context, opts Options, cfg *config.Config, root string, shell sandbox.Shell, sink event.Sink, timer *phaseTimer) (promptAssembly, error) {
|
||||
sysPrompt, err := cfg.ResolveSystemPromptForRoot(root)
|
||||
if err != nil {
|
||||
@@ -51,6 +53,9 @@ func buildPromptAssembly(ctx context.Context, opts Options, cfg *config.Config,
|
||||
report(sink, event.Event{Level: event.LevelWarn, Text: err.Error() + "; falling back to inline/default system prompt"})
|
||||
sysPrompt = cfg.InlineSystemPrompt()
|
||||
}
|
||||
// Persona edits enter the cache-stable prefix at the next runtime build.
|
||||
p := persona.FromConfig(cfg.Agent.Persona)
|
||||
sysPrompt = persona.ApplyPrefix(sysPrompt, p)
|
||||
// Output style: fold the selected persona/tone block into the base prompt
|
||||
// before language/memory/skills append, so a "replace" style (keep-coding
|
||||
// false) still keeps those. Applied once, into the cache-stable prefix.
|
||||
@@ -58,10 +63,14 @@ func buildPromptAssembly(ctx context.Context, opts Options, cfg *config.Config,
|
||||
sysPrompt = outputstyle.Apply(sysPrompt, st)
|
||||
}
|
||||
sysPrompt = appendCorePolicies(sysPrompt)
|
||||
sysPrompt = persona.ApplySuffix(sysPrompt, p)
|
||||
timer.mark("prompt")
|
||||
// Role settings, the workspace path and its version control ride the per-turn
|
||||
// transient blocks, so this prefix is identical for every project on the
|
||||
// machine; per-project text added here would diverge every byte after it.
|
||||
|
||||
// The probe still runs when the persona withholds runtime context: its
|
||||
// persisted snapshot keeps later boots stable. Only the section is dropped.
|
||||
if cfg.EnvironmentEnabled() {
|
||||
shellLabel := shell.Kind.String()
|
||||
if strings.TrimSpace(cfg.Tools.Shell.Path) != "" {
|
||||
@@ -83,12 +92,14 @@ func buildPromptAssembly(ctx context.Context, opts Options, cfg *config.Config,
|
||||
shellLabel,
|
||||
cfg.Environment.Tools,
|
||||
)
|
||||
if envSection != "" {
|
||||
if envSection != "" && p.IncludeRuntimeContext {
|
||||
sysPrompt += "\n\n" + envSection
|
||||
}
|
||||
}
|
||||
timer.mark("environment")
|
||||
sysPrompt = appendOfflineEnvironmentNote(sysPrompt, cfg.Environment.Offline)
|
||||
if p.IncludeRuntimeContext {
|
||||
sysPrompt = appendOfflineEnvironmentNote(sysPrompt, cfg.Environment.Offline)
|
||||
}
|
||||
|
||||
// Memory folds in exactly here, once, becoming part of the durable prefix,
|
||||
// so it costs nothing per turn. Mid-session changes ride the controller's
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
// TestBuildComposesByteStableSystemPrompt is the boot-level byte-stability
|
||||
@@ -64,6 +66,61 @@ api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
}
|
||||
}
|
||||
|
||||
// A persona joins the same cached prefix, so it must compose deterministically
|
||||
// and land where the assembly contract says: prefix ahead of the base prompt,
|
||||
// suffix after the first-party policies.
|
||||
func TestBuildComposesByteStablePersonaPrompt(t *testing.T) {
|
||||
home := isolateConfigHome(t)
|
||||
t.Setenv("REASONIX_HOME", filepath.Join(home, ".reasonix"))
|
||||
t.Setenv("REASONIX_STATE_HOME", "")
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
|
||||
writeFile(t, os.Getenv("REASONIX_HOME"), "config.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE SYSTEM PROMPT"
|
||||
|
||||
[agent.persona]
|
||||
prefix = "PERSONA IDENTITY"
|
||||
suffix = "PERSONA SUFFIX"
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "openai"
|
||||
base_url = "https://example.invalid"
|
||||
model = "x"
|
||||
api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
`)
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
opts := Options{Home: os.Getenv("REASONIX_HOME"), WorkspaceRoot: dir, resolvedShell: pinnedEffectShell()}
|
||||
build := func() string {
|
||||
b, err := Build(context.Background(), opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
prompt := systemMessage(b.History())
|
||||
b.Close()
|
||||
return prompt
|
||||
}
|
||||
firstPrompt := build()
|
||||
secondPrompt := build()
|
||||
|
||||
if firstPrompt != secondPrompt {
|
||||
t.Fatalf("persona prompt is not byte-stable across identical Builds: %q vs %q",
|
||||
firstDivergence(firstPrompt, secondPrompt), firstDivergence(secondPrompt, firstPrompt))
|
||||
}
|
||||
if !strings.HasPrefix(firstPrompt, "PERSONA IDENTITY") {
|
||||
t.Fatalf("persona prefix does not lead the prompt: %q", firstPrompt[:min(len(firstPrompt), 120)])
|
||||
}
|
||||
suffix := strings.Index(firstPrompt, "PERSONA SUFFIX")
|
||||
if suffix < strings.Index(firstPrompt, config.ExternalContentPolicy) {
|
||||
t.Fatalf("persona suffix at %d does not follow the first-party policies", suffix)
|
||||
}
|
||||
}
|
||||
|
||||
// firstDivergence returns a small window around the first differing byte so a
|
||||
// failure names the drifting prompt section instead of dumping both prompts.
|
||||
func firstDivergence(a, b string) string {
|
||||
|
||||
@@ -1098,10 +1098,10 @@ type AgentConfig struct {
|
||||
PlanApproval string `toml:"plan_approval"` // "ask" (default) or "auto": the plan card answers itself; the user file only
|
||||
GoalAutoRetry int `toml:"goal_auto_retry"` // 0 (default) stops at an unavailable completion check; N retries it up to N times in a row; the user file only
|
||||
HostVerification string `toml:"host_verification"` // "on" (default) or "off"; the user file only
|
||||
// OutputStyle selects a persona/tone block folded into the system prompt at
|
||||
// startup (a built-in like "explanatory"/"learning"/"concise", or a custom
|
||||
// .reasonix/output-styles/<name>.md). Empty = the unmodified prompt.
|
||||
OutputStyle string `toml:"output_style"`
|
||||
// OutputStyle selects built-in tone guidance or .reasonix/output-styles/<name>.md.
|
||||
// Empty leaves the prompt unmodified.
|
||||
OutputStyle string `toml:"output_style"`
|
||||
Persona PersonaConfig `toml:"persona"`
|
||||
// Deprecated compatibility field. Automatic plan mode was retired in config
|
||||
// version 5; old TOML remains readable, but loading normalizes it to "off"
|
||||
// and rendering omits it. Plan mode remains available as an explicit user
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PersonaConfig is the user's system persona. The zero value means "no
|
||||
// persona": the prompt is assembled exactly as if this feature did not exist,
|
||||
// which is what keeps configs written before it readable.
|
||||
type PersonaConfig struct {
|
||||
Prefix string `toml:"prefix"`
|
||||
Suffix string `toml:"suffix"`
|
||||
// IncludeRuntimeContext is a pointer so absent (nil) means true, matching
|
||||
// zero-value-is-no-persona; explicit false suppresses the probed environment
|
||||
// section and the offline note.
|
||||
IncludeRuntimeContext *bool `toml:"include_runtime_context"`
|
||||
}
|
||||
|
||||
const (
|
||||
// maxPersonaPartBytes bounds each half; the total is bounded separately so a
|
||||
// future field cannot turn two permitted halves into an unbounded prefix.
|
||||
maxPersonaPartBytes = 32 << 10
|
||||
maxPersonaTotalBytes = 64 << 10
|
||||
)
|
||||
|
||||
var ErrPersonaTooLarge = errors.New("persona is too large")
|
||||
|
||||
// Normalize trims the bodies and collapses a persona that contributes nothing,
|
||||
// so an empty [agent.persona] table and an absent one are the same config. An
|
||||
// explicit include_runtime_context is a contribution even with no text: it is a
|
||||
// standalone switch, and collapsing it would make the save read back unchanged.
|
||||
func (p PersonaConfig) Normalize() PersonaConfig {
|
||||
p.Prefix = strings.TrimSpace(p.Prefix)
|
||||
p.Suffix = strings.TrimSpace(p.Suffix)
|
||||
if p.Prefix == "" && p.Suffix == "" && p.IncludeRuntimeContext == nil {
|
||||
return PersonaConfig{}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Validate bounds the user-authored additions to the system prompt.
|
||||
func (p PersonaConfig) Validate() error {
|
||||
p = p.Normalize()
|
||||
if len(p.Prefix) > maxPersonaPartBytes || len(p.Suffix) > maxPersonaPartBytes || len(p.Prefix)+len(p.Suffix) > maxPersonaTotalBytes {
|
||||
return fmt.Errorf("%w: prefix and suffix must each be at most 32 KiB", ErrPersonaTooLarge)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RuntimeContextEnabled reports whether optional runtime context may enter the
|
||||
// prompt. Absent means enabled, so only an explicit false suppresses it.
|
||||
func (p PersonaConfig) RuntimeContextEnabled() bool {
|
||||
return p.IncludeRuntimeContext == nil || *p.IncludeRuntimeContext
|
||||
}
|
||||
|
||||
// IsZero reports whether the persona contributes anything, after trimming.
|
||||
func (p PersonaConfig) IsZero() bool {
|
||||
p = p.Normalize()
|
||||
return p.Prefix == "" && p.Suffix == "" && p.IncludeRuntimeContext == nil
|
||||
}
|
||||
|
||||
// SetPersona normalizes and stores the user's system persona. Persistence picks
|
||||
// the user config; the running session keeps the prompt it was built with.
|
||||
func (c *Config) SetPersona(p PersonaConfig) error {
|
||||
p = p.Normalize()
|
||||
if err := p.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Agent.Persona = p
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderPersonaTable writes the [agent.persona] table. Absent fields are not
|
||||
// written, so a field the user never touched round-trips as absent; the
|
||||
// pointer-only field is written only when the user made a choice.
|
||||
func renderPersonaTable(b *strings.Builder, p PersonaConfig) {
|
||||
b.WriteString("\n[agent.persona]\n")
|
||||
if p.Prefix != "" {
|
||||
b.WriteString("prefix = " + tomlMultilineBasicString(p.Prefix) + "\n")
|
||||
}
|
||||
if p.Suffix != "" {
|
||||
b.WriteString("suffix = " + tomlMultilineBasicString(p.Suffix) + "\n")
|
||||
}
|
||||
if p.IncludeRuntimeContext != nil {
|
||||
fmt.Fprintf(b, "include_runtime_context = %v # false omits the probed environment section and offline note\n", *p.IncludeRuntimeContext)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package config
|
||||
|
||||
func clonePersona(p PersonaConfig) PersonaConfig {
|
||||
if p.IncludeRuntimeContext != nil {
|
||||
value := *p.IncludeRuntimeContext
|
||||
p.IncludeRuntimeContext = &value
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestProjectPersonaCannotOverrideUserOnRead(t *testing.T) {
|
||||
for _, user := range []string{"", "[agent.persona]\nprefix = 'USER'\nsuffix = 'USER TAIL'\ninclude_runtime_context = true\n"} {
|
||||
for _, readOnly := range []bool{false, true} {
|
||||
t.Run(user+map[bool]string{false: "/load", true: "/read-only"}[readOnly], func(t *testing.T) {
|
||||
home, root := testenv.TempDir(t), testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(user), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := LoadForEdit(UserConfigPath()).Agent.Persona
|
||||
project := "[agent.persona]\nprefix = 'REPO'\nsuffix = 'REPO TAIL'\ncomplete = true\ninclude_runtime_context = false\n"
|
||||
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(project), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
load := LoadForRoot
|
||||
if readOnly {
|
||||
load = LoadForRootReadOnly
|
||||
}
|
||||
got, err := load(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Agent.Persona, want) {
|
||||
t.Fatalf("project changed user's persona: got %+v, want %+v", got.Agent.Persona, want)
|
||||
}
|
||||
for _, ignored := range got.IgnoredProjectSettings() {
|
||||
if ignored.Key == "agent.persona" && ignored.Reason == ProjectUserOnly {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("missing agent.persona user-only diagnostic")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestPersonaNormalizeTrimsAndCollapsesOnlyTheEmptyChoice(t *testing.T) {
|
||||
zero := PersonaConfig{}.Normalize()
|
||||
if !reflect.DeepEqual(zero, PersonaConfig{}) {
|
||||
t.Fatalf("zero config normalized to %+v", zero)
|
||||
}
|
||||
blank := PersonaConfig{Prefix: " \n\t", Suffix: " "}.Normalize()
|
||||
if !reflect.DeepEqual(blank, PersonaConfig{}) {
|
||||
t.Fatalf("blank bodies normalized to %+v, want zero", blank)
|
||||
}
|
||||
trimmed := PersonaConfig{Prefix: " lead ", Suffix: " tail \n"}.Normalize()
|
||||
if trimmed.Prefix != "lead" || trimmed.Suffix != "tail" {
|
||||
t.Fatalf("normalized = %+v", trimmed)
|
||||
}
|
||||
// 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: new(false)}.Normalize()
|
||||
if pointerOnly.IsZero() || pointerOnly.IncludeRuntimeContext == nil || *pointerOnly.IncludeRuntimeContext {
|
||||
t.Fatalf("pointer-only persona collapsed or lost: %+v", pointerOnly)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonaValidateRejectsUnhonorableValues(t *testing.T) {
|
||||
tooBig := strings.Repeat("x", maxPersonaPartBytes)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
p PersonaConfig
|
||||
want error
|
||||
}{
|
||||
{"prefix over cap", PersonaConfig{Prefix: tooBig + "x"}, ErrPersonaTooLarge},
|
||||
{"suffix over cap", PersonaConfig{Suffix: tooBig + "x"}, ErrPersonaTooLarge},
|
||||
} {
|
||||
if err := tc.p.Validate(); !errors.Is(err, tc.want) {
|
||||
t.Errorf("%s: Validate = %v, want %v", tc.name, err, tc.want)
|
||||
}
|
||||
}
|
||||
if err := (PersonaConfig{Prefix: tooBig, Suffix: tooBig}).Validate(); err != nil {
|
||||
t.Fatalf("two parts at the cap = %v, want accepted", err)
|
||||
}
|
||||
var c Config
|
||||
if err := c.SetPersona(PersonaConfig{Prefix: tooBig + "x"}); !errors.Is(err, ErrPersonaTooLarge) {
|
||||
t.Fatalf("SetPersona = %v, want ErrPersonaTooLarge", err)
|
||||
}
|
||||
if !c.Agent.Persona.IsZero() {
|
||||
t.Fatalf("a rejected persona reached the config: %+v", c.Agent.Persona)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonaRuntimeContextDefaultsEnabled(t *testing.T) {
|
||||
if !(PersonaConfig{}).RuntimeContextEnabled() {
|
||||
t.Fatal("absent include_runtime_context reads as disabled")
|
||||
}
|
||||
if !(PersonaConfig{IncludeRuntimeContext: new(true)}).RuntimeContextEnabled() {
|
||||
t.Fatal("explicit true reads as disabled")
|
||||
}
|
||||
if (PersonaConfig{IncludeRuntimeContext: new(false)}).RuntimeContextEnabled() {
|
||||
t.Fatal("explicit false reads as enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// Render → load must return the same persona, including the pointer-only case
|
||||
// where the runtime-context switch is the user's whole configuration.
|
||||
func TestPersonaRendersAndLoadsUnchanged(t *testing.T) {
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
path := UserConfigPath()
|
||||
for _, want := range []PersonaConfig{
|
||||
{Prefix: "IDENTITY", Suffix: "TAIL", IncludeRuntimeContext: new(false)},
|
||||
{Prefix: "IDENTITY", IncludeRuntimeContext: new(true)},
|
||||
{Suffix: "TAIL"},
|
||||
{IncludeRuntimeContext: new(false)},
|
||||
} {
|
||||
c := Default()
|
||||
if err := c.SetPersona(want); err != nil {
|
||||
t.Fatalf("SetPersona(%+v): %v", want, err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rendered := RenderTOMLForScope(c, RenderScopeUser)
|
||||
if err := os.WriteFile(path, []byte(rendered), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded := LoadForEdit(path).Agent.Persona
|
||||
if !reflect.DeepEqual(loaded, want.Normalize()) {
|
||||
t.Fatalf("round-trip %+v:\nloaded %+v\nrendered:\n%s", want, loaded, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonaZeroRendersNothing(t *testing.T) {
|
||||
rendered := RenderTOMLForScope(Default(), RenderScopeUser)
|
||||
if strings.Contains(rendered, "[agent.persona]") {
|
||||
t.Fatalf("an absent persona rendered a section:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonaNeverEntersProjectConfig(t *testing.T) {
|
||||
c := Default()
|
||||
if err := c.SetPersona(PersonaConfig{Prefix: "IDENTITY", Suffix: "TAIL"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rendered := RenderTOMLForScope(c, RenderScopeProject); strings.Contains(rendered, "[agent.persona]") {
|
||||
t.Fatalf("project render carried a user-level persona:\n%s", rendered)
|
||||
}
|
||||
if delta := RenderTOMLProjectDelta(c); strings.Contains(delta, "[agent.persona]") {
|
||||
t.Fatalf("project delta carried a user-level persona:\n%s", delta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldUserConfigWithoutPersonaStillLoads(t *testing.T) {
|
||||
path, _ := seedUserConfigFrom1x(t)
|
||||
loaded := LoadForEdit(path)
|
||||
if !loaded.Agent.Persona.IsZero() {
|
||||
t.Fatalf("old config grew a persona: %+v", loaded.Agent.Persona)
|
||||
}
|
||||
if rendered := RenderTOMLForScope(loaded, RenderScopeUser); strings.Contains(rendered, "[agent.persona]") {
|
||||
t.Fatalf("saving an old config added a persona section:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ type heldScope struct {
|
||||
// turnNarration is the user's [agent] turn_narration. It adds output tokens
|
||||
// to every turn, so a cloned repo must not turn it on.
|
||||
turnNarration string
|
||||
persona PersonaConfig
|
||||
peers PeersConfig
|
||||
// mcpResources decides whether a server's resources are readable by the model.
|
||||
mcpResources bool
|
||||
@@ -95,6 +96,7 @@ func holdUserScope(c *Config) heldScope {
|
||||
layaLocal: c.Tools.SystemOne.Laya.Local,
|
||||
rememberDevices: c.Serve.RememberPairedDevices,
|
||||
turnNarration: c.TurnNarration(),
|
||||
persona: clonePersona(c.Agent.Persona),
|
||||
peers: clonePeers(c.Peers),
|
||||
mcpResources: c.Tools.MCPResources,
|
||||
|
||||
@@ -249,6 +251,10 @@ func (h heldScope) narrow(c *Config, r Roots, root string, projectMeta toml.Meta
|
||||
c.ignoreProject("agent.turn_narration", c.TurnNarration(), ProjectUserOnly)
|
||||
}
|
||||
c.Agent.TurnNarration = h.turnNarration
|
||||
if !reflect.DeepEqual(c.Agent.Persona, h.persona) {
|
||||
c.ignoreProject("agent.persona", "", ProjectUserOnly)
|
||||
}
|
||||
c.Agent.Persona = clonePersona(h.persona)
|
||||
h.narrowSandbox(c, ws)
|
||||
h.narrowPermissions(c)
|
||||
if !maps.Equal(c.Agent.RoleEfforts, h.endpoints.agent.RoleEfforts) {
|
||||
|
||||
@@ -221,6 +221,10 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
|
||||
} else {
|
||||
b.WriteString("# output_style = \"explanatory\" # explanatory | learning | concise | custom; empty = default\n")
|
||||
}
|
||||
// Persona is the user's own identity, so it never goes into a project file.
|
||||
if p := c.Agent.Persona.Normalize(); scope != RenderScopeProject && !p.IsZero() {
|
||||
renderPersonaTable(&b, p)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
if shouldRenderProviders(c, defaults, scope) {
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
|
||||
// renderAgentDelta writes the first half of the [agent] scalar delta onto buf
|
||||
// and reports whether it wrote any line. Split from renderAgentTail so neither
|
||||
// half carries more branches than the complexity limit allows.
|
||||
// half carries more branches than the complexity limit allows. Persona is absent
|
||||
// on purpose: it is user-level, so it never enters a project delta.
|
||||
func renderAgentDelta(buf *strings.Builder, c, d *Config) bool {
|
||||
wrote := false
|
||||
if sp := strings.TrimSpace(c.Agent.SystemPrompt); sp != "" && sp != d.Agent.SystemPrompt {
|
||||
|
||||
@@ -76,6 +76,9 @@ var table = []Entry{
|
||||
{ID: "auto_archive.days", Class: Preference, Effect: Immediate},
|
||||
{ID: "agent.system_prompt", Class: Guarded, Effect: OnOpen},
|
||||
{ID: "agent.system_prompt_file", Class: Guarded, Effect: OnOpen},
|
||||
{ID: "agent.persona.prefix", Class: Guarded, Effect: OnOpen},
|
||||
{ID: "agent.persona.suffix", Class: Guarded, Effect: OnOpen},
|
||||
{ID: "agent.persona.include_runtime_context", Class: Guarded, Effect: OnOpen},
|
||||
{ID: "agent.max_steps", Class: Guarded, Effect: OnOpen},
|
||||
{ID: "agent.planner_max_steps", Class: Guarded, Effect: OnOpen},
|
||||
{ID: "agent.temperature", Class: Preference, Effect: OnOpen},
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"reasonix/internal/contract/config"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPersonaKeysCannotBeWrittenThroughModelProposals(t *testing.T) {
|
||||
for _, id := range []string{"agent.persona.prefix", "agent.persona.suffix", "agent.persona.include_runtime_context"} {
|
||||
e, ok := Lookup(id)
|
||||
if !ok || e.Class != Guarded || e.Effect != OnOpen {
|
||||
t.Fatalf("persona boundary missing from settings catalog: %s %+v", id, e)
|
||||
}
|
||||
cfg := config.Default()
|
||||
if _, _, err := Write(cfg, id, []byte(`"untrusted proposal"`)); CodeOf(err) != CodeGuarded {
|
||||
t.Fatalf("model could propose a persona change: %s %v", id, err)
|
||||
}
|
||||
if !cfg.Agent.Persona.IsZero() {
|
||||
t.Fatal("refused proposal changed persona")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func TestBoundaryKeysAreGuarded(t *testing.T) {
|
||||
"agent.max_steps", "agent.max_subagent_depth", "agent.max_subagent_concurrency", "agent.max_parallel_writers",
|
||||
"agent.task_token_budget", "agent.guardian_model", "agent.system_prompt", "agent.screen_external_content",
|
||||
"tools.shell.path", "tools.enabled", "statusline.command", "skills.paths", "providers", "plugins",
|
||||
"peers.release_projects", "serve.token", "serve.auth_mode", "secrets.protect_sensitive_files", "storage",
|
||||
"peers.release_projects", "serve.token", "serve.auth_mode", "secrets.protect_sensitive_files", "storage", "attachments.max_stream_bytes",
|
||||
} {
|
||||
if e, ok := Lookup(id); !ok || e.Class != Guarded {
|
||||
t.Errorf("%s = %+v, want Guarded", id, e)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// persona_settings.go — the user's system persona as HTTP. Saving writes the
|
||||
// user config and rebuilds nothing: the prompt is the provider-cached prefix,
|
||||
// so the saved persona applies at the next session rebuild or new session.
|
||||
package serve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
func (s *Server) registerPersonaRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /persona", s.personaSettings)
|
||||
mux.HandleFunc("POST /persona", s.savePersonaSettings)
|
||||
}
|
||||
|
||||
func (s *Server) personaSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, s.ctl().PersonaSettings())
|
||||
}
|
||||
|
||||
func (s *Server) savePersonaSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Prefix string `json:"prefix"`
|
||||
Suffix string `json:"suffix"`
|
||||
// A pointer so a client that omits the field keeps the default (enabled)
|
||||
// instead of saving an explicit false it never showed the user.
|
||||
IncludeRuntimeContext *bool `json:"include_runtime_context"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10)).Decode(&body); err != nil {
|
||||
badBody(w)
|
||||
return
|
||||
}
|
||||
p := config.PersonaConfig{
|
||||
Prefix: body.Prefix,
|
||||
Suffix: body.Suffix,
|
||||
IncludeRuntimeContext: body.IncludeRuntimeContext,
|
||||
}
|
||||
if err := s.ctl().SavePersonaSettings(p); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, config.ErrPersonaTooLarge):
|
||||
saveFailed(w, http.StatusBadRequest, "persona.too_large", err)
|
||||
default:
|
||||
// A write that failed for none of the persona reasons is the generic
|
||||
// internal class, which the frontend already words; a persona-specific
|
||||
// code would need a translation this change does not own.
|
||||
saveFailed(w, http.StatusInternalServerError, "internal.failed", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, s.ctl().PersonaSettings())
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
func personaRefusal(t *testing.T, resp *http.Response) (int, string) {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
var out struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode refusal: %v", err)
|
||||
}
|
||||
return resp.StatusCode, out.Code
|
||||
}
|
||||
|
||||
func TestPersonaSettingsRouteRoundTripAndValidation(t *testing.T) {
|
||||
s := newProviderEditServer(t)
|
||||
srv := httptest.NewServer(operatorHandler(s))
|
||||
defer srv.Close()
|
||||
|
||||
got := readJSON[control.PersonaSettings](t, srv.URL, "/persona")
|
||||
if got.Prefix != "" || got.Suffix != "" || !got.IncludeRuntimeContext {
|
||||
t.Fatalf("unset persona reads %+v, want empty with runtime context enabled", got)
|
||||
}
|
||||
if got.Path != config.UserConfigPath() {
|
||||
t.Fatalf("path = %q, want the user config", got.Path)
|
||||
}
|
||||
if len(got.Presets) == 0 || got.Presets[0].ID == "" {
|
||||
t.Fatalf("GET served no presets: %+v", got.Presets)
|
||||
}
|
||||
|
||||
big := strings.Repeat("x", 33<<10)
|
||||
if status, code := personaRefusal(t, postProvider(t, srv.URL, "/persona", fmt.Sprintf(`{"prefix":%q}`, big))); status != http.StatusBadRequest || code != "persona.too_large" {
|
||||
t.Fatalf("oversized persona = %d %q", status, code)
|
||||
}
|
||||
if status, code := personaRefusal(t, postProvider(t, srv.URL, "/persona", `{`)); status != http.StatusBadRequest || code != "request.bad_body" {
|
||||
t.Fatalf("malformed body = %d %q", status, code)
|
||||
}
|
||||
if refusedCfg := config.LoadForEdit(config.UserConfigPath()); !refusedCfg.Agent.Persona.IsZero() {
|
||||
t.Fatalf("a refused save reached the user config: %+v", refusedCfg.Agent.Persona)
|
||||
}
|
||||
|
||||
ok := postProvider(t, srv.URL, "/persona", `{"prefix":" IDENTITY ","suffix":"TAIL","include_runtime_context":false}`)
|
||||
defer ok.Body.Close()
|
||||
if ok.StatusCode != http.StatusOK {
|
||||
t.Fatalf("save = %d", ok.StatusCode)
|
||||
}
|
||||
var saved control.PersonaSettings
|
||||
if err := json.NewDecoder(ok.Body).Decode(&saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Prefix != "IDENTITY" || saved.Suffix != "TAIL" || saved.IncludeRuntimeContext {
|
||||
t.Fatalf("saved response = %+v, want normalized values with runtime context off", saved)
|
||||
}
|
||||
stored := config.LoadForEdit(config.UserConfigPath()).Agent.Persona
|
||||
if stored.Prefix != "IDENTITY" || stored.Suffix != "TAIL" || stored.RuntimeContextEnabled() {
|
||||
t.Fatalf("user config holds %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
// The runtime-context switch is a standalone choice: saving only that must read
|
||||
// back as saved, not be dropped by normalization.
|
||||
func TestPersonaSettingsRouteKeepsPointerOnlySwitch(t *testing.T) {
|
||||
s := newProviderEditServer(t)
|
||||
srv := httptest.NewServer(operatorHandler(s))
|
||||
defer srv.Close()
|
||||
|
||||
resp := postProvider(t, srv.URL, "/persona", `{"include_runtime_context":false}`)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("save = %d", resp.StatusCode)
|
||||
}
|
||||
var saved control.PersonaSettings
|
||||
if err := json.NewDecoder(resp.Body).Decode(&saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Prefix != "" || saved.Suffix != "" || saved.IncludeRuntimeContext {
|
||||
t.Fatalf("pointer-only save reads %+v, want runtime context off and no text", saved)
|
||||
}
|
||||
again := readJSON[control.PersonaSettings](t, srv.URL, "/persona")
|
||||
if again.IncludeRuntimeContext {
|
||||
t.Fatalf("GET forgot the saved switch: %+v", again)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ func (s *Server) routes() http.Handler {
|
||||
mux.HandleFunc("POST /model-mode", s.modelMode)
|
||||
mux.HandleFunc("POST /compact", s.compact)
|
||||
s.registerCompactionRoutes(mux)
|
||||
s.registerPersonaRoutes(mux)
|
||||
s.registerProgressWatchRoutes(mux)
|
||||
s.registerChangedFilesRoutes(mux)
|
||||
mux.HandleFunc("POST /new", s.newSession)
|
||||
|
||||
@@ -3,11 +3,28 @@ package testutil
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
type cancellationObservedContext struct {
|
||||
context.Context
|
||||
observed chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (c *cancellationObservedContext) Err() error {
|
||||
err := c.Context.Err()
|
||||
if err != nil {
|
||||
c.once.Do(func() { close(c.observed) })
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func TestMockProviderStreamHonorsCanceledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
@@ -26,7 +43,9 @@ func TestMockProviderStreamHonorsCanceledContext(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMockProviderStreamStopsOnContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
parent, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ctx := &cancellationObservedContext{Context: parent, observed: make(chan struct{})}
|
||||
mp := NewMock("mock", Turn{
|
||||
Text: "first",
|
||||
ToolCalls: []provider.ToolCall{
|
||||
@@ -42,6 +61,13 @@ func TestMockProviderStreamStopsOnContextCancellation(t *testing.T) {
|
||||
t.Fatalf("first chunk text = %q, want first", got)
|
||||
}
|
||||
cancel()
|
||||
// Cancellation and a pending send can both be selectable. Wait until the
|
||||
// producer observes cancellation before requiring its next error chunk.
|
||||
select {
|
||||
case <-ctx.observed:
|
||||
case <-time.After(testenv.Budget(t)):
|
||||
t.Fatal("producer did not observe context cancellation")
|
||||
}
|
||||
chunk, ok := <-ch
|
||||
if !ok {
|
||||
t.Fatal("stream closed without returning cancellation error")
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Package persona adds user-authored prefix and suffix text around first-party
|
||||
// guidance without replacing it. Optional runtime context is independently
|
||||
// controlled. The cache-stable result is assembled at boot; saved changes apply
|
||||
// to new sessions and the next rebuild of an existing session.
|
||||
package persona
|
||||
@@ -0,0 +1,44 @@
|
||||
package persona
|
||||
|
||||
import "reasonix/internal/contract/config"
|
||||
|
||||
// Settings is a persona resolved for composition: the config value normalized,
|
||||
// with the tri-state runtime-context pointer already resolved to a bool.
|
||||
type Settings struct {
|
||||
Prefix string
|
||||
Suffix string
|
||||
IncludeRuntimeContext bool
|
||||
}
|
||||
|
||||
// FromConfig resolves the stored persona. Normalization here is what makes an
|
||||
// old config (no persona at all) compose exactly like one with an empty table.
|
||||
func FromConfig(c config.PersonaConfig) Settings {
|
||||
p := c.Normalize()
|
||||
return Settings{
|
||||
Prefix: p.Prefix,
|
||||
Suffix: p.Suffix,
|
||||
IncludeRuntimeContext: p.RuntimeContextEnabled(),
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyPrefix puts the identity ahead of the first-party guidance.
|
||||
func ApplyPrefix(base string, s Settings) string {
|
||||
if s.Prefix == "" {
|
||||
return base
|
||||
}
|
||||
if base == "" {
|
||||
return s.Prefix
|
||||
}
|
||||
return s.Prefix + "\n\n" + base
|
||||
}
|
||||
|
||||
// ApplySuffix trails the first-party guidance with the user's suffix.
|
||||
func ApplySuffix(base string, s Settings) string {
|
||||
if s.Suffix == "" {
|
||||
return base
|
||||
}
|
||||
if base == "" {
|
||||
return s.Suffix
|
||||
}
|
||||
return base + "\n\n" + s.Suffix
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package persona
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
func TestPresetsAreStableAndHitOnlyOnePlacement(t *testing.T) {
|
||||
got := Presets()
|
||||
want := []string{"engineer", "concise", "mentor", "critic"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("Presets() = %d entries, want %d", len(got), len(want))
|
||||
}
|
||||
for i, id := range want {
|
||||
if got[i].ID != id {
|
||||
t.Fatalf("preset %d = %q, want %q", i, got[i].ID, id)
|
||||
}
|
||||
if got[i].Prefix == "" && got[i].Suffix == "" {
|
||||
t.Fatalf("preset %q contributes nothing", id)
|
||||
}
|
||||
if !got[i].IncludeRuntimeContext {
|
||||
t.Fatalf("preset %q suppresses runtime context; that is an explicit user choice", id)
|
||||
}
|
||||
}
|
||||
// A returned slice is a copy: a caller reordering it must not reorder the
|
||||
// shipped order on the next call.
|
||||
got[0], got[1] = got[1], got[0]
|
||||
if Presets()[0].ID != "engineer" {
|
||||
t.Fatal("Presets() exposed the kernel's slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromConfigNormalizesAndResolvesRuntimeContext(t *testing.T) {
|
||||
if s := FromConfig(config.PersonaConfig{}); (s != Settings{IncludeRuntimeContext: true}) {
|
||||
t.Fatalf("zero config = %+v, want runtime context enabled", s)
|
||||
}
|
||||
s := FromConfig(config.PersonaConfig{Prefix: " lead ", Suffix: " trail ", IncludeRuntimeContext: new(false)})
|
||||
if s.Prefix != "lead" || s.Suffix != "trail" || s.IncludeRuntimeContext {
|
||||
t.Fatalf("FromConfig = %+v, want trimmed text and runtime context off", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPrefixAndSuffixPreserveBase(t *testing.T) {
|
||||
s := Settings{Prefix: "IDENTITY", Suffix: "TAIL"}
|
||||
if got := ApplyPrefix("BASE", s); got != "IDENTITY\n\nBASE" {
|
||||
t.Fatalf("ApplyPrefix = %q", got)
|
||||
}
|
||||
if got := ApplySuffix("BASE", s); got != "BASE\n\nTAIL" {
|
||||
t.Fatalf("ApplySuffix = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPrefixAndSuffixHandleAnEmptyBase(t *testing.T) {
|
||||
s := Settings{Prefix: "IDENTITY", Suffix: "TAIL"}
|
||||
if got := strings.TrimSpace(ApplyPrefix("", s)); got != "IDENTITY" {
|
||||
t.Fatalf("ApplyPrefix on empty base = %q", got)
|
||||
}
|
||||
if got := strings.TrimSpace(ApplySuffix("", s)); got != "TAIL" {
|
||||
t.Fatalf("ApplySuffix on empty base = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package persona
|
||||
|
||||
// Preset is a built-in starting point the Studio page can apply with one click.
|
||||
// It is only a fill-the-editor convenience: saving persists the literal fields,
|
||||
// never a preset id, so editing a preset-derived persona is just an edit.
|
||||
type Preset struct {
|
||||
ID string `json:"id"`
|
||||
Prefix string `json:"prefix"`
|
||||
Suffix string `json:"suffix"`
|
||||
IncludeRuntimeContext bool `json:"include_runtime_context"`
|
||||
}
|
||||
|
||||
// presets are the shipped starting points, in the order the page shows them.
|
||||
// None is a default: no persona is the default.
|
||||
var presets = []Preset{
|
||||
{
|
||||
ID: "engineer",
|
||||
IncludeRuntimeContext: true,
|
||||
Prefix: "You are a rigorous staff engineer. Verify before you claim: read the code, run the check, " +
|
||||
"and report what you observed rather than what should happen. Make the smallest correct change, " +
|
||||
"and state the residual risk and what you did not verify.",
|
||||
},
|
||||
{
|
||||
ID: "concise",
|
||||
IncludeRuntimeContext: true,
|
||||
Suffix: "Reply tersely: no preamble, no restating the request, no summary of what you just did. " +
|
||||
"Prefer code and short bullets to paragraphs, and use the fewest words that stay unambiguous.",
|
||||
},
|
||||
{
|
||||
ID: "mentor",
|
||||
IncludeRuntimeContext: true,
|
||||
Prefix: "You are a patient teacher guiding someone through this codebase. Explain the reasoning behind " +
|
||||
"each change, connect it to the concepts involved, and surface the trade-offs instead of hiding them.",
|
||||
Suffix: "Give the trade-offs and your recommendation, then leave the decision to the user; do not take " +
|
||||
"over choices that are theirs to make.",
|
||||
},
|
||||
{
|
||||
ID: "critic",
|
||||
IncludeRuntimeContext: true,
|
||||
Suffix: "Act as an adversarial reviewer. Before approving anything, name the failure mode you looked for " +
|
||||
"and the evidence that rules it out; when you cannot, say so and request the specific check that " +
|
||||
"would settle it.",
|
||||
},
|
||||
}
|
||||
|
||||
// Presets returns the built-in starting points in stable order. The slice is a
|
||||
// copy so a frontend cannot reorder or mutate the kernel's list.
|
||||
func Presets() []Preset {
|
||||
return append([]Preset(nil), presets...)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/runtime/persona"
|
||||
)
|
||||
|
||||
// PersonaSettings is the user's system persona as Studio edits it: the three
|
||||
// stored fields, the user config path it lives in, and the built-in starting
|
||||
// points. Presets ride the same response so the page cannot drift from the
|
||||
// kernel's list.
|
||||
type PersonaSettings struct {
|
||||
Prefix string `json:"prefix"`
|
||||
Suffix string `json:"suffix"`
|
||||
IncludeRuntimeContext bool `json:"include_runtime_context"`
|
||||
Path string `json:"path"`
|
||||
Presets []persona.Preset `json:"presets"`
|
||||
}
|
||||
|
||||
// PersonaSettings reports the persona stored in the user config. The persona is
|
||||
// composed once into the cache-stable prompt prefix at boot, so this value is
|
||||
// what the next runtime build will get, not necessarily what the running one has.
|
||||
func (c *Controller) PersonaSettings() PersonaSettings {
|
||||
path := config.UserConfigPath()
|
||||
p := config.LoadForEdit(path).Agent.Persona.Normalize()
|
||||
return PersonaSettings{
|
||||
Prefix: p.Prefix,
|
||||
Suffix: p.Suffix,
|
||||
IncludeRuntimeContext: p.RuntimeContextEnabled(),
|
||||
Path: path,
|
||||
Presets: persona.Presets(),
|
||||
}
|
||||
}
|
||||
|
||||
// SavePersonaSettings persists the persona to the user config only and rebuilds
|
||||
// nothing: the prompt bytes belong to the provider-cached prefix, so the running
|
||||
// session keeps its prompt until its next rebuild (for example, a model switch).
|
||||
func (c *Controller) SavePersonaSettings(p config.PersonaConfig) error {
|
||||
unlock := config.LockUserConfigEdits()
|
||||
defer unlock()
|
||||
path := config.UserConfigPath()
|
||||
cfg := config.LoadForEdit(path)
|
||||
if err := cfg.SetPersona(p); err != nil {
|
||||
return fmt.Errorf("persona: %w", err)
|
||||
}
|
||||
return cfg.SaveTo(path)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
func TestPersonaSettingsReadWriteAndValidation(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
path := config.UserConfigPath()
|
||||
raw := "# user note\n[agent]\ntemperature = 0.3\n"
|
||||
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := New(Options{})
|
||||
defer c.Close()
|
||||
|
||||
got := c.PersonaSettings()
|
||||
if got.Path != path || got.Prefix != "" || got.Suffix != "" || !got.IncludeRuntimeContext {
|
||||
t.Fatalf("unset persona reads %+v", got)
|
||||
}
|
||||
if len(got.Presets) == 0 {
|
||||
t.Fatal("GET served no presets")
|
||||
}
|
||||
|
||||
if err := c.SavePersonaSettings(config.PersonaConfig{Prefix: strings.Repeat("x", 33<<10)}); !errors.Is(err, config.ErrPersonaTooLarge) {
|
||||
t.Fatalf("oversized persona = %v", err)
|
||||
}
|
||||
if onDisk, _ := os.ReadFile(path); string(onDisk) != raw {
|
||||
t.Fatal("a refused save changed the user config")
|
||||
}
|
||||
|
||||
off := false
|
||||
if err := c.SavePersonaSettings(config.PersonaConfig{Prefix: " IDENTITY ", Suffix: "TAIL", IncludeRuntimeContext: &off}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved := c.PersonaSettings()
|
||||
if saved.Prefix != "IDENTITY" || saved.Suffix != "TAIL" || saved.IncludeRuntimeContext {
|
||||
t.Fatalf("saved persona = %+v, want normalized values", saved)
|
||||
}
|
||||
onDisk, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"# user note", "[agent.persona]", "include_runtime_context = false", "prefix = "} {
|
||||
if !strings.Contains(string(onDisk), want) {
|
||||
t.Fatalf("user config lacks %q:\n%s", want, onDisk)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,6 +302,8 @@ type RuntimeSettings interface {
|
||||
SaveFoldWorkSettings(fold bool) error
|
||||
TurnNarrationSettings() TurnNarrationSettings
|
||||
SaveTurnNarrationSettings(level string) error
|
||||
PersonaSettings() PersonaSettings
|
||||
SavePersonaSettings(in config.PersonaConfig) error
|
||||
WebAccessSettings() WebAccessSettings
|
||||
SaveWebAccessSettings(patch WebAccessPatch) (WebAccessSettings, error)
|
||||
ProbeSearxng(ctx context.Context) (websearch.SearxngProbe, error)
|
||||
|
||||
@@ -72,6 +72,13 @@ compact_ratio = 0.85 # sole auto trigger; presets 0.70 (active) / 0.80 (s
|
||||
# max_parallel_writers = 3 # concurrent writers with non-overlapping write_paths
|
||||
# output_style = "explanatory" # persona/tone folded into the prompt: explanatory | learning | concise,
|
||||
# or a custom .reasonix/output-styles/<name>.md ; empty = default
|
||||
# [agent.persona] # the agent's identity, as opposed to output_style's tone.
|
||||
# # prefix leads the system prompt, suffix follows the built-in guidance;
|
||||
# # include_runtime_context = false drops the probed environment section and
|
||||
# # the offline note. User-level only; applies on the next session build.
|
||||
# prefix = "You are the staff engineer who owns this change end to end."
|
||||
# suffix = "Close with the risk you did not verify."
|
||||
# include_runtime_context = true
|
||||
# embedded_diff_detection = true # mark a shell result whose whole output is a unified diff (e.g. `git diff`)
|
||||
# so the transcript renders it as a diff instead of flat text; default false
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
File and change counts in the workspace explorer now follow the interface text-size setting explicitly, without relying on the browser's default small-text sizing. (#13588)
|
||||
工作区文件面板的文件数和改动数明确跟随界面字号设置,不再依赖浏览器默认的小号文字规则。 (#13588)
|
||||
@@ -0,0 +1,2 @@
|
||||
- 新增:Studio「人设」页提供用户级前后缀及运行时上下文开关;内置指令保留,项目不能设置人设。保存后在新会话或下次会话重建时生效。关联 #13588。
|
||||
- New: Studio Persona settings add user-level prefix/suffix text and a runtime-context switch, preserving built-in guidance. Applies at the next session build. Refs #13588.
|
||||
Reference in new issue
Block a user