feat: add request-scoped context policies (#2914)

* feat: add request-scoped context policies

Add named turn profiles under agents.defaults so callers can opt into
per-request context and tool policies without changing default chat behavior.

Profiles can disable history, system context, skill prompts, or tools, and can
limit skills/tools with allow lists. Wire profile selection through Pico message
payloads, agent turn execution, Web chat selection, and Web visual config.

Reject invalid turn profiles before saving config through Web APIs and document
the new request context policy behavior.

* fix: address turn profile review blockers

* feat: simplify request context policy config

* fix: suppress tool prompt when turn tools are disabled

* fix: enforce turn profile tool restrictions
This commit is contained in:
lxowalle
2026-05-22 10:06:40 +08:00
committed by GitHub
parent 5bbebb5fc8
commit 2992eccbf0
39 changed files with 3150 additions and 162 deletions
+4
View File
@@ -312,6 +312,10 @@ func validateConfig(cfg *config.Config) []string {
errs = append(errs, err.Error())
}
if err := cfg.ValidateTurnProfile(); err != nil {
errs = append(errs, err.Error())
}
// Gateway port range
if cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) {
errs = append(errs, fmt.Sprintf("gateway.port %d is out of valid range (1-65535)", cfg.Gateway.Port))
+218 -25
View File
@@ -13,6 +13,95 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
)
func TestHandlePatchConfig_PreservesTurnProfile(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.Agents.Defaults.TurnProfile = config.TurnProfileConfig{
Enabled: true,
History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff},
SystemPrompt: config.TurnProfileBlock{Mode: config.TurnProfileModeOff},
Skills: config.TurnProfileBlock{Mode: config.TurnProfileModeOff},
Tools: config.TurnProfileBlock{
Mode: config.TurnProfileModeCustom,
Allow: []string{"web_search", "web_fetch"},
},
}
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
"agents": {
"defaults": {
"max_tokens": 1234
}
}
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
updated, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig(updated) error = %v", err)
}
profile := updated.Agents.Defaults.TurnProfile
if profile.Tools.Mode != config.TurnProfileModeCustom ||
strings.Join(profile.Tools.Allow, ",") != "web_search,web_fetch" {
t.Fatalf("profile tools = %#v, want custom web_search/web_fetch", profile.Tools)
}
}
func TestHandlePatchConfig_RejectsInvalidTurnProfile(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
"agents": {
"defaults": {
"turn_profile": {
"enabled": true,
"history": { "mode": "custom" }
}
}
}
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf(
"status = %d, want %d, body=%s",
rec.Code,
http.StatusBadRequest,
rec.Body.String(),
)
}
if !strings.Contains(rec.Body.String(), "history.mode custom is not supported") {
t.Fatalf("body=%s, want turn profile validation error", rec.Body.String())
}
if _, err := config.LoadConfig(configPath); err != nil {
t.Fatalf("LoadConfig() after rejected patch error = %v", err)
}
}
func assertGatewayLogLevelApplied(t *testing.T, method, body string, want logger.LogLevel) {
t.Helper()
@@ -35,7 +124,13 @@ func assertGatewayLogLevelApplied(t *testing.T, method, body string, want logger
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s /api/config status = %d, want %d, body=%s", method, rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"%s /api/config status = %d, want %d, body=%s",
method,
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
if got := logger.GetLevel(); got != want {
t.Fatalf("logger.GetLevel() = %v, want %v", got, want)
@@ -141,10 +236,18 @@ func TestHandlePatchConfig_RejectsInvalidExecRegexPatterns(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
t.Fatalf(
"status = %d, want %d, body=%s",
rec.Code,
http.StatusBadRequest,
rec.Body.String(),
)
}
if !bytes.Contains(rec.Body.Bytes(), []byte("custom_deny_patterns")) {
t.Fatalf("expected validation error mentioning custom_deny_patterns, body=%s", rec.Body.String())
t.Fatalf(
"expected validation error mentioning custom_deny_patterns, body=%s",
rec.Body.String(),
)
}
}
@@ -200,7 +303,12 @@ func TestHandlePatchConfig_SavesChannelListSettingsPatch(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
cfg, err := config.LoadConfig(configPath)
@@ -264,7 +372,12 @@ func TestHandlePatchConfig_NormalizesStringChannelArrayFields(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
cfg, err := config.LoadConfig(configPath)
@@ -277,7 +390,10 @@ func TestHandlePatchConfig_NormalizesStringChannelArrayFields(t *testing.T) {
picoChannel.AllowFrom[0] != "ou_a" ||
picoChannel.AllowFrom[1] != "ou_b" ||
picoChannel.AllowFrom[2] != "ou_c" {
t.Fatalf("pico allow_from = %#v, want [\"ou_a\", \"ou_b\", \"ou_c\"]", picoChannel.AllowFrom)
t.Fatalf(
"pico allow_from = %#v, want [\"ou_a\", \"ou_b\", \"ou_c\"]",
picoChannel.AllowFrom,
)
}
if len(picoChannel.GroupTrigger.Prefixes) != 3 ||
picoChannel.GroupTrigger.Prefixes[0] != "/" ||
@@ -346,7 +462,12 @@ func TestHandlePatchConfig_NormalizesSingleNumericAllowFrom(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
cfg, err := config.LoadConfig(configPath)
@@ -420,7 +541,11 @@ func TestHandlePatchConfig_RejectsInvalidChannelArrayFields(t *testing.T) {
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(tt.body))
req := httptest.NewRequest(
http.MethodPatch,
"/api/config",
bytes.NewBufferString(tt.body),
)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
@@ -439,8 +564,12 @@ func TestHandlePatchConfig_RejectsInvalidChannelArrayFields(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
telegramChannel := cfg.Channels[config.ChannelTelegram]
if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "existing-user" {
t.Fatalf("telegram allow_from = %#v, want unchanged [\"existing-user\"]", telegramChannel.AllowFrom)
if len(telegramChannel.AllowFrom) != 1 ||
telegramChannel.AllowFrom[0] != "existing-user" {
t.Fatalf(
"telegram allow_from = %#v, want unchanged [\"existing-user\"]",
telegramChannel.AllowFrom,
)
}
})
}
@@ -471,10 +600,18 @@ func TestHandlePatchConfig_RejectsNegativeStreamingDeliveryValues(t *testing.T)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusBadRequest,
rec.Body.String(),
)
}
if !strings.Contains(rec.Body.String(), "streaming.throttle_seconds") {
t.Fatalf("response body = %q, want streaming.throttle_seconds validation error", rec.Body.String())
t.Fatalf(
"response body = %q, want streaming.throttle_seconds validation error",
rec.Body.String(),
)
}
}
@@ -520,7 +657,12 @@ func TestHandlePatchConfig_ClearingAllowFromDoesNotLeaveEmptyStringItem(t *testi
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
cfg, err = config.LoadConfig(configPath)
@@ -537,7 +679,10 @@ func TestHandlePatchConfig_ClearingAllowFromDoesNotLeaveEmptyStringItem(t *testi
t.Fatalf("ReadFile(configPath) error = %v", err)
}
if strings.Contains(string(configData), `"allow_from": [""]`) {
t.Fatalf("config file should not contain empty-string allow_from item: %s", string(configData))
t.Fatalf(
"config file should not contain empty-string allow_from item: %s",
string(configData),
)
}
}
@@ -575,7 +720,12 @@ func TestHandlePatchConfig_CreatesMissingChannelWithTypeAndSecret(t *testing.T)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
cfg, err = config.LoadConfig(configPath)
@@ -696,7 +846,12 @@ func TestHandleUpdateConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PUT /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PUT /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
}
@@ -719,7 +874,12 @@ func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
}
@@ -778,7 +938,12 @@ func TestHandlePatchConfig_PreservesDebugFlagOverride(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
if got := logger.GetLevel(); got != logger.DEBUG {
t.Fatalf("logger.GetLevel() = %v, want %v", got, logger.DEBUG)
@@ -808,7 +973,12 @@ func TestHandlePatchConfig_SavesDiscordTokenFromPayload(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
cfg, err := config.LoadConfig(configPath)
@@ -852,7 +1022,12 @@ func TestHandlePatchConfig_DoesNotPersistShadowRegistryAuthTokenField(t *testing
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf(
"PATCH /api/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
cfg, err := config.LoadConfig(configPath)
@@ -875,7 +1050,10 @@ func TestHandlePatchConfig_DoesNotPersistShadowRegistryAuthTokenField(t *testing
t.Fatalf("ReadFile(configPath) error = %v", err)
}
if strings.Contains(string(rawConfig), "_auth_token") {
t.Fatalf("config.json should not persist _auth_token shadow field, got:\n%s", string(rawConfig))
t.Fatalf(
"config.json should not persist _auth_token shadow field, got:\n%s",
string(rawConfig),
)
}
}
@@ -911,7 +1089,11 @@ func testCommandPatterns(t *testing.T, configPath string, body string) *httptest
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPost, "/api/config/test-command-patterns", bytes.NewBufferString(body))
req := httptest.NewRequest(
http.MethodPost,
"/api/config/test-command-patterns",
bytes.NewBufferString(body),
)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
@@ -954,7 +1136,10 @@ func TestHandleTestCommandPatterns_MatchesBlacklistNotWhitelist(t *testing.T) {
t.Fatalf("expected blocked=true, body=%s", rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
t.Fatalf("expected allowed=false when blacklist matches but not whitelist, body=%s", rec.Body.String())
t.Fatalf(
"expected allowed=false when blacklist matches but not whitelist, body=%s",
rec.Body.String(),
)
}
}
@@ -1028,7 +1213,10 @@ func TestHandleTestCommandPatterns_InvalidRegexSkipped(t *testing.T) {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
t.Fatalf("expected allowed=true, invalid pattern skipped and valid one matched, body=%s", rec.Body.String())
t.Fatalf(
"expected allowed=true, invalid pattern skipped and valid one matched, body=%s",
rec.Body.String(),
)
}
}
@@ -1068,7 +1256,12 @@ func TestHandleTestCommandPatterns_InvalidJSON(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
t.Fatalf(
"status = %d, want %d, body=%s",
rec.Code,
http.StatusBadRequest,
rec.Body.String(),
)
}
}
@@ -1,5 +1,5 @@
import { IconArrowUp, IconPhotoPlus, IconX } from "@tabler/icons-react"
import type { KeyboardEvent } from "react"
import { useRef, type KeyboardEvent as ReactKeyboardEvent } from "react"
import { useTranslation } from "react-i18next"
import TextareaAutosize from "react-textarea-autosize"
@@ -52,14 +52,25 @@ export function ChatComposer({
}: ChatComposerProps) {
const { t } = useTranslation()
const canInput = inputDisabledReason === null
const composingRef = useRef(false)
const disabledMessage =
inputDisabledReason === null
? null
: t(`chat.disabledPlaceholder.${inputDisabledReason}`)
const placeholder = disabledMessage ?? t("chat.placeholder")
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing) return
const handleKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
const nativeEvent = e.nativeEvent as Event & {
isComposing?: boolean
keyCode?: number
}
if (
composingRef.current ||
nativeEvent.isComposing ||
nativeEvent.keyCode === 229
) {
return
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
onSend()
@@ -98,6 +109,12 @@ export function ChatComposer({
<TextareaAutosize
value={input}
onChange={(e) => onInputChange(e.target.value)}
onCompositionStart={() => {
composingRef.current = true
}}
onCompositionEnd={() => {
composingRef.current = false
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={!canInput}
@@ -351,7 +351,7 @@ export function ChatPage() {
<div
ref={scrollRef}
onScroll={handleScroll}
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 [scrollbar-gutter:stable] md:px-8 lg:px-24 xl:px-48"
className="min-h-0 flex-1 [scrollbar-gutter:stable] overflow-y-auto px-4 py-6 md:px-8 lg:px-24 xl:px-48"
>
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8">
{messages.length === 0 && !isTyping && (
@@ -32,6 +32,7 @@ import {
EMPTY_LAUNCHER_FORM,
type LauncherForm,
type MCPServerForm,
type TurnProfileForm,
buildFormFromConfig,
parseCIDRText,
parseFloatField,
@@ -40,7 +41,6 @@ import {
parseMultilineList,
} from "@/components/config/form-model"
import { PageHeader } from "@/components/page-header"
import { Badge } from "@/components/ui/badge"
import {
AlertDialog,
AlertDialogAction,
@@ -52,6 +52,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway"
@@ -71,6 +72,33 @@ function buildStringMapMergePatch(
return patch
}
function buildTurnProfilePatch(
profile: TurnProfileForm,
): Record<string, unknown> {
const result: Record<string, unknown> = {
enabled: profile.enabled,
history: { mode: profile.historyMode },
system_prompt: { mode: profile.systemPromptMode },
skills: { mode: profile.skillsMode },
tools: { mode: profile.toolsMode },
}
if (profile.skillsMode === "custom") {
result.skills = {
mode: "custom",
allow: parseMultilineList(profile.skillsAllowText),
}
}
if (profile.toolsMode === "custom") {
result.tools = {
mode: "custom",
allow: parseMultilineList(profile.toolsAllowText),
}
}
return result
}
export function ConfigPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
@@ -221,6 +249,13 @@ export function ConfigPage() {
)
}
const handleTurnProfileFieldChange = <K extends keyof TurnProfileForm>(
key: K,
value: TurnProfileForm[K],
) => {
updateField("turnProfile", { ...form.turnProfile, [key]: value })
}
const handleReset = () => {
setForm(baseline)
setLauncherForm(launcherBaseline)
@@ -314,6 +349,7 @@ export function ConfigPage() {
"Summarize token percent",
{ min: 1, max: 100 },
)
const turnProfile = buildTurnProfilePatch(form.turnProfile)
const heartbeatInterval = parseIntField(
form.heartbeatInterval,
"Heartbeat interval",
@@ -548,6 +584,7 @@ export function ConfigPage() {
max_tool_iterations: maxToolIterations,
summarize_message_threshold: summarizeMessageThreshold,
summarize_token_percent: summarizeTokenPercent,
turn_profile: turnProfile,
},
},
session: {
@@ -757,7 +794,11 @@ export function ConfigPage() {
disabled={saving || isLauncherLoading}
/>
<AgentDefaultsSection form={form} onFieldChange={updateField} />
<AgentDefaultsSection
form={form}
onFieldChange={updateField}
onTurnProfileFieldChange={handleTurnProfileFieldChange}
/>
<RuntimeSection form={form} onFieldChange={updateField} />
@@ -9,6 +9,8 @@ import {
type LauncherForm,
type MCPServerForm,
type MCPServerType,
type TurnProfileForm,
type TurnProfileMode,
} from "@/components/config/form-model"
import { Field, SwitchCardField } from "@/components/shared-form"
import { Button } from "@/components/ui/button"
@@ -66,13 +68,49 @@ function ConfigSectionCard({
interface AgentDefaultsSectionProps {
form: CoreConfigForm
onFieldChange: UpdateCoreField
onTurnProfileFieldChange: <K extends keyof TurnProfileForm>(
key: K,
value: TurnProfileForm[K],
) => void
}
export function AgentDefaultsSection({
form,
onFieldChange,
onTurnProfileFieldChange,
}: AgentDefaultsSectionProps) {
const { t } = useTranslation()
const renderModeSelect = ({
value,
onValueChange,
allowCustom,
}: {
value: TurnProfileMode
onValueChange: (mode: TurnProfileMode) => void
allowCustom: boolean
}) => (
<Select
value={value}
onValueChange={(next) => onValueChange(next as TurnProfileMode)}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
{t("pages.config.turn_profile_mode_default")}
</SelectItem>
<SelectItem value="off">
{t("pages.config.turn_profile_mode_off")}
</SelectItem>
{allowCustom && (
<SelectItem value="custom">
{t("pages.config.turn_profile_mode_custom")}
</SelectItem>
)}
</SelectContent>
</Select>
)
return (
<ConfigSectionCard title={t("pages.config.sections.agent")}>
@@ -215,6 +253,116 @@ export function AgentDefaultsSection({
}
/>
</Field>
<Field
label={t("pages.config.turn_profile")}
hint={t("pages.config.turn_profile_hint")}
layout="setting-row"
controlClassName="md:max-w-[42rem]"
>
<div className="space-y-3">
<SwitchCardField
label={t("pages.config.turn_profile_enabled")}
hint={t("pages.config.turn_profile_enabled_hint")}
checked={form.turnProfile.enabled}
onCheckedChange={(checked) =>
onTurnProfileFieldChange("enabled", checked)
}
/>
<div className="grid gap-3 lg:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">
{t("pages.config.turn_profile_history")}
</label>
{renderModeSelect({
value: form.turnProfile.historyMode,
onValueChange: (mode) =>
onTurnProfileFieldChange(
"historyMode",
mode === "off" ? "off" : "default",
),
allowCustom: false,
})}
<p className="text-muted-foreground text-xs leading-relaxed">
{t("pages.config.turn_profile_history_hint")}
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
{t("pages.config.turn_profile_system_prompt")}
</label>
{renderModeSelect({
value: form.turnProfile.systemPromptMode,
onValueChange: (mode) =>
onTurnProfileFieldChange(
"systemPromptMode",
mode === "off" ? "off" : "default",
),
allowCustom: false,
})}
<p className="text-muted-foreground text-xs leading-relaxed">
{t("pages.config.turn_profile_system_prompt_hint")}
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
{t("pages.config.turn_profile_skills")}
</label>
{renderModeSelect({
value: form.turnProfile.skillsMode,
onValueChange: (mode) =>
onTurnProfileFieldChange("skillsMode", mode),
allowCustom: true,
})}
<p className="text-muted-foreground text-xs leading-relaxed">
{t("pages.config.turn_profile_skills_hint")}
</p>
{form.turnProfile.skillsMode === "custom" && (
<Textarea
value={form.turnProfile.skillsAllowText}
onChange={(e) =>
onTurnProfileFieldChange("skillsAllowText", e.target.value)
}
placeholder={t(
"pages.config.turn_profile_skills_allow_placeholder",
)}
className="min-h-20 font-mono text-xs"
/>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
{t("pages.config.turn_profile_tools")}
</label>
{renderModeSelect({
value: form.turnProfile.toolsMode,
onValueChange: (mode) =>
onTurnProfileFieldChange("toolsMode", mode),
allowCustom: true,
})}
<p className="text-muted-foreground text-xs leading-relaxed">
{t("pages.config.turn_profile_tools_hint")}
</p>
{form.turnProfile.toolsMode === "custom" && (
<Textarea
value={form.turnProfile.toolsAllowText}
onChange={(e) =>
onTurnProfileFieldChange("toolsAllowText", e.target.value)
}
placeholder={t(
"pages.config.turn_profile_tools_allow_placeholder",
)}
className="min-h-20 font-mono text-xs"
/>
)}
</div>
</div>
</div>
</Field>
</ConfigSectionCard>
)
}
@@ -20,6 +20,7 @@ export interface CoreConfigForm {
maxToolIterations: string
summarizeMessageThreshold: string
summarizeTokenPercent: string
turnProfile: TurnProfileForm
dmScope: string
heartbeatEnabled: boolean
heartbeatInterval: string
@@ -43,6 +44,18 @@ export interface CoreConfigForm {
export type MCPServerType = "http" | "sse" | "stdio"
export type TurnProfileMode = "default" | "off" | "custom"
export interface TurnProfileForm {
enabled: boolean
historyMode: Exclude<TurnProfileMode, "custom">
systemPromptMode: Exclude<TurnProfileMode, "custom">
skillsMode: TurnProfileMode
skillsAllowText: string
toolsMode: TurnProfileMode
toolsAllowText: string
}
export interface MCPServerForm {
id: string
name: string
@@ -116,6 +129,15 @@ export const EMPTY_FORM: CoreConfigForm = {
maxToolIterations: "50",
summarizeMessageThreshold: "20",
summarizeTokenPercent: "75",
turnProfile: {
enabled: false,
historyMode: "default",
systemPromptMode: "default",
skillsMode: "default",
skillsAllowText: "",
toolsMode: "default",
toolsAllowText: "",
},
dmScope: "per-channel-peer",
heartbeatEnabled: true,
heartbeatInterval: "30",
@@ -222,6 +244,46 @@ function mapMCPServers(value: unknown): MCPServerForm[] {
})
}
function toTurnProfileMode(value: unknown): TurnProfileMode {
if (value === "off" || value === "custom") {
return value
}
return "default"
}
function toBasicTurnProfileMode(
value: unknown,
): Exclude<TurnProfileMode, "custom"> {
return value === "off" ? "off" : "default"
}
function allowListText(value: unknown): string {
if (!Array.isArray(value)) {
return ""
}
return value
.filter((item): item is string => typeof item === "string")
.join("\n")
}
function mapTurnProfile(value: unknown): TurnProfileForm {
const profile = asRecord(value)
const history = asRecord(profile.history)
const systemPrompt = asRecord(profile.system_prompt)
const skills = asRecord(profile.skills)
const tools = asRecord(profile.tools)
return {
enabled: asBool(profile.enabled),
historyMode: toBasicTurnProfileMode(history.mode),
systemPromptMode: toBasicTurnProfileMode(systemPrompt.mode),
skillsMode: toTurnProfileMode(skills.mode),
skillsAllowText: allowListText(skills.allow),
toolsMode: toTurnProfileMode(tools.mode),
toolsAllowText: allowListText(tools.allow),
}
}
export function buildFormFromConfig(config: unknown): CoreConfigForm {
const root = asRecord(config)
const agents = asRecord(root.agents)
@@ -310,6 +372,7 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
defaults.summarize_token_percent,
EMPTY_FORM.summarizeTokenPercent,
),
turnProfile: mapTurnProfile(defaults.turn_profile),
dmScope: asString(session.dm_scope) || EMPTY_FORM.dmScope,
heartbeatEnabled:
heartbeat.enabled === undefined
+6 -4
View File
@@ -357,14 +357,16 @@ export function sendChatMessage({
}))
try {
const payload: Record<string, unknown> = {
content: normalizedContent,
media: normalizedAttachments.map((attachment) => attachment.url),
}
socket.send(
JSON.stringify({
type: "message.send",
id,
payload: {
content: normalizedContent,
media: normalizedAttachments.map((attachment) => attachment.url),
},
payload,
}),
)
return true
+17
View File
@@ -788,6 +788,23 @@
"summarize_threshold_hint": "Start summarization after this many messages.",
"summarize_token_percent": "Summarize Token Percent",
"summarize_token_percent_hint": "Used when conversation summary is triggered.",
"turn_profile": "Request Context Policy",
"turn_profile_hint": "Controls what context each request carries. Leave disabled to keep the normal chat behavior.",
"turn_profile_enabled": "Enable policy",
"turn_profile_enabled_hint": "When enabled, this policy applies to every new turn. When disabled, PicoClaw uses the original context behavior.",
"turn_profile_mode_default": "Default",
"turn_profile_mode_off": "Off",
"turn_profile_mode_custom": "Allow List",
"turn_profile_history": "History Context",
"turn_profile_history_hint": "Default includes earlier messages from this session. Off makes the turn behave like a fresh chat and skips saving its result back to history.",
"turn_profile_system_prompt": "System Context",
"turn_profile_system_prompt_hint": "Default includes PicoClaw identity, workspace, memory, and runtime instructions. Off keeps only system prompts explicitly supplied by the request.",
"turn_profile_skills": "Skill Prompts",
"turn_profile_skills_hint": "Default includes available skills and active skill instructions. Off hides them. Allow List keeps only skill names entered one per line.",
"turn_profile_skills_allow_placeholder": "skill-name\nanother-skill",
"turn_profile_tools": "Callable Tools",
"turn_profile_tools_hint": "Default exposes normal tools. Off prevents tool calls. Allow List keeps only tool names entered one per line, such as web_search.",
"turn_profile_tools_allow_placeholder": "web_search\nweb_fetch",
"session_scope": "Session Scope",
"session_scope_hint": "How chat context is isolated across peers/channels.",
"session_scope_per_channel_peer": "Per Channel + Peer",
+17
View File
@@ -788,6 +788,23 @@
"summarize_threshold_hint": "Iniciar resumo após este número de mensagens.",
"summarize_token_percent": "Percentual de Token para Resumir",
"summarize_token_percent_hint": "Usado quando o resumo da conversa é acionado.",
"turn_profile": "Política de Contexto da Requisição",
"turn_profile_hint": "Controla qual contexto cada requisição carrega. Deixe desativado para manter o comportamento normal do chat.",
"turn_profile_enabled": "Ativar política",
"turn_profile_enabled_hint": "Quando ativada, esta política vale para cada novo turno. Quando desativada, o PicoClaw usa o comportamento original de contexto.",
"turn_profile_mode_default": "Padrão",
"turn_profile_mode_off": "Desligado",
"turn_profile_mode_custom": "Lista Permitida",
"turn_profile_history": "Contexto de Histórico",
"turn_profile_history_hint": "Padrão inclui mensagens anteriores desta sessão. Desligado faz o turno parecer um chat novo e não salva o resultado no histórico.",
"turn_profile_system_prompt": "Contexto de Sistema",
"turn_profile_system_prompt_hint": "Padrão inclui identidade, workspace, memória e instruções de runtime do PicoClaw. Desligado mantém apenas prompts de sistema enviados explicitamente pela requisição.",
"turn_profile_skills": "Prompts de Skills",
"turn_profile_skills_hint": "Padrão inclui skills disponíveis e instruções de skills ativas. Desligado oculta isso. Lista Permitida mantém apenas nomes de skills, um por linha.",
"turn_profile_skills_allow_placeholder": "skill-name\nanother-skill",
"turn_profile_tools": "Ferramentas Chamáveis",
"turn_profile_tools_hint": "Padrão expõe as ferramentas normais. Desligado impede chamadas de ferramenta. Lista Permitida mantém apenas nomes de ferramentas, um por linha, como web_search.",
"turn_profile_tools_allow_placeholder": "web_search\nweb_fetch",
"session_scope": "Escopo da Sessão",
"session_scope_hint": "Como o contexto do chat é isolado entre peers/canais.",
"session_scope_per_channel_peer": "Por Canal + Peer",
+17
View File
@@ -788,6 +788,23 @@
"summarize_threshold_hint": "消息数量达到该值后开始触发摘要",
"summarize_token_percent": "摘要目标 Token 百分比",
"summarize_token_percent_hint": "在触发会话摘要时使用",
"turn_profile": "请求上下文策略",
"turn_profile_hint": "控制每次请求会带入哪些上下文。保持关闭时,普通对话完全沿用原逻辑。",
"turn_profile_enabled": "启用策略",
"turn_profile_enabled_hint": "启用后,每个新回合都会应用下面的设置;关闭时,PicoClaw 使用原来的上下文行为。",
"turn_profile_mode_default": "默认",
"turn_profile_mode_off": "关闭",
"turn_profile_mode_custom": "只允许列表",
"turn_profile_history": "历史上下文",
"turn_profile_history_hint": "默认会带上这段会话之前的消息;关闭后,本轮像一次新对话,也不会把结果写回历史。",
"turn_profile_system_prompt": "系统上下文",
"turn_profile_system_prompt_hint": "默认会带上 PicoClaw 的身份、工作区、记忆和运行时说明;关闭后只保留外部明确传入的 system prompt。",
"turn_profile_skills": "技能提示",
"turn_profile_skills_hint": "默认会把可用技能和已激活技能写入提示;关闭后不写入;只允许列表时每行填写一个技能名。",
"turn_profile_skills_allow_placeholder": "skill-name\nanother-skill",
"turn_profile_tools": "可调用工具",
"turn_profile_tools_hint": "默认暴露正常工具;关闭后模型不能调用工具;只允许列表时每行填写一个工具名,例如 web_search。",
"turn_profile_tools_allow_placeholder": "web_search\nweb_fetch",
"session_scope": "会话隔离范围",
"session_scope_hint": "定义不同用户/频道之间如何隔离会话上下文",
"session_scope_per_channel_peer": "按频道+用户隔离",