mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -399,6 +399,7 @@ type AgentDefaults struct {
|
||||
SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker
|
||||
ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"`
|
||||
ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"`
|
||||
TurnProfile TurnProfileConfig `json:"turn_profile,omitempty"`
|
||||
MaxLLMRetries int `json:"max_llm_retries,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_LLM_RETRIES"`
|
||||
LLMRetryBackoffSecs int `json:"llm_retry_backoff_secs,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_LLM_RETRY_BACKOFF_SECS"`
|
||||
}
|
||||
@@ -1418,6 +1419,9 @@ func LoadConfig(path string) (*Config, error) {
|
||||
if err = InitChannelList(cfg.Channels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = cfg.ValidateTurnProfile(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Gateway.Host, err = resolveGatewayHostFromEnv(gatewayHostBeforeEnv)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid gateway host: %w", err)
|
||||
|
||||
@@ -160,6 +160,130 @@ func TestAgentConfig_FullParse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnProfileConfig_ParseAndResolve(t *testing.T) {
|
||||
jsonData := `{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"turn_profile": {
|
||||
"enabled": true,
|
||||
"history": {"mode": "off"},
|
||||
"system_prompt": {"mode": "off"},
|
||||
"skills": {"mode": "off"},
|
||||
"tools": {
|
||||
"mode": "custom",
|
||||
"allow": ["web_search", "web_fetch"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
cfg := DefaultConfig()
|
||||
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if err := cfg.ValidateTurnProfile(); err != nil {
|
||||
t.Fatalf("ValidateTurnProfile() error = %v", err)
|
||||
}
|
||||
|
||||
profile, ok, err := cfg.Agents.Defaults.ResolveTurnProfile()
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveTurnProfile() error = %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("ResolveTurnProfile() ok = false, want true")
|
||||
}
|
||||
if profile.HistoryMode != TurnProfileModeOff ||
|
||||
profile.SystemPromptMode != TurnProfileModeOff ||
|
||||
profile.SkillsMode != TurnProfileModeOff ||
|
||||
profile.ToolsMode != TurnProfileModeCustom {
|
||||
t.Fatalf("resolved clean_web modes = %+v", profile)
|
||||
}
|
||||
assert.Equal(t, []string{"web_search", "web_fetch"}, profile.AllowedTools)
|
||||
}
|
||||
|
||||
func TestTurnProfileConfig_DisabledOrMissingIsNoop(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
profile, ok, err := cfg.Agents.Defaults.ResolveTurnProfile()
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveTurnProfile(missing) error = %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("ResolveTurnProfile(missing) ok = true, want false")
|
||||
}
|
||||
if profile.Enabled {
|
||||
t.Fatalf("ResolveTurnProfile(missing) profile.Enabled = true, want false")
|
||||
}
|
||||
|
||||
cfg.Agents.Defaults.TurnProfile = TurnProfileConfig{
|
||||
Enabled: false,
|
||||
History: TurnProfileBlock{
|
||||
Mode: TurnProfileModeOff,
|
||||
},
|
||||
}
|
||||
profile, ok, err = cfg.Agents.Defaults.ResolveTurnProfile()
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveTurnProfile(disabled) error = %v", err)
|
||||
}
|
||||
if ok || profile.Enabled {
|
||||
t.Fatalf("disabled profile = (%+v, %v), want no-op", profile, ok)
|
||||
}
|
||||
|
||||
cfg.Agents.Defaults.TurnProfile = TurnProfileConfig{
|
||||
Enabled: false,
|
||||
History: TurnProfileBlock{
|
||||
Mode: TurnProfileModeCustom,
|
||||
},
|
||||
Tools: TurnProfileBlock{
|
||||
Mode: TurnProfileMode("sometimes"),
|
||||
},
|
||||
}
|
||||
if err := cfg.ValidateTurnProfile(); err != nil {
|
||||
t.Fatalf("ValidateTurnProfile(disabled unsupported modes) error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnProfileConfig_ValidationRejectsUnsupportedModes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "history custom unsupported",
|
||||
raw: `{"agents":{"defaults":{"turn_profile":{"enabled":true,"history":{"mode":"custom"}}}}}`,
|
||||
want: "history.mode",
|
||||
},
|
||||
{
|
||||
name: "system prompt custom unsupported",
|
||||
raw: `{"agents":{"defaults":{"turn_profile":{"enabled":true,"system_prompt":{"mode":"custom"}}}}}`,
|
||||
want: "system_prompt.mode",
|
||||
},
|
||||
{
|
||||
name: "unknown mode",
|
||||
raw: `{"agents":{"defaults":{"turn_profile":{"enabled":true,"tools":{"mode":"sometimes"}}}}}`,
|
||||
want: "unsupported mode",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if err := json.Unmarshal([]byte(tt.raw), cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
err := cfg.ValidateTurnProfile()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateTurnProfile() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("ValidateTurnProfile() error = %v, want containing %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_MCPMaxInlineTextChars(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if cfg.Tools.MCP.GetMaxInlineTextChars() != DefaultMCPMaxInlineTextChars {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type TurnProfileMode string
|
||||
|
||||
const (
|
||||
TurnProfileModeDefault TurnProfileMode = "default"
|
||||
TurnProfileModeOff TurnProfileMode = "off"
|
||||
TurnProfileModeCustom TurnProfileMode = "custom"
|
||||
)
|
||||
|
||||
type TurnProfileConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
History TurnProfileBlock `json:"history,omitempty"`
|
||||
SystemPrompt TurnProfileBlock `json:"system_prompt,omitempty"`
|
||||
Skills TurnProfileBlock `json:"skills,omitempty"`
|
||||
Tools TurnProfileBlock `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type TurnProfileBlock struct {
|
||||
Mode TurnProfileMode `json:"mode,omitempty"`
|
||||
Allow []string `json:"allow,omitempty"`
|
||||
}
|
||||
|
||||
type EffectiveTurnProfile struct {
|
||||
Enabled bool
|
||||
HistoryMode TurnProfileMode
|
||||
SystemPromptMode TurnProfileMode
|
||||
SkillsMode TurnProfileMode
|
||||
ToolsMode TurnProfileMode
|
||||
AllowedSkills []string
|
||||
AllowedTools []string
|
||||
}
|
||||
|
||||
func (m TurnProfileMode) Effective() TurnProfileMode {
|
||||
switch TurnProfileMode(strings.ToLower(strings.TrimSpace(string(m)))) {
|
||||
case "", TurnProfileModeDefault:
|
||||
return TurnProfileModeDefault
|
||||
case TurnProfileModeOff:
|
||||
return TurnProfileModeOff
|
||||
case TurnProfileModeCustom:
|
||||
return TurnProfileModeCustom
|
||||
default:
|
||||
return TurnProfileMode(strings.ToLower(strings.TrimSpace(string(m))))
|
||||
}
|
||||
}
|
||||
|
||||
func (d *AgentDefaults) ResolveTurnProfile() (EffectiveTurnProfile, bool, error) {
|
||||
if d == nil {
|
||||
return EffectiveTurnProfile{}, false, nil
|
||||
}
|
||||
profile := d.TurnProfile
|
||||
if !profile.Enabled {
|
||||
return EffectiveTurnProfile{}, false, nil
|
||||
}
|
||||
if err := validateTurnProfile(profile); err != nil {
|
||||
return EffectiveTurnProfile{}, false, err
|
||||
}
|
||||
return EffectiveTurnProfile{
|
||||
Enabled: true,
|
||||
HistoryMode: profile.History.Mode.Effective(),
|
||||
SystemPromptMode: profile.SystemPrompt.Mode.Effective(),
|
||||
SkillsMode: profile.Skills.Mode.Effective(),
|
||||
ToolsMode: profile.Tools.Mode.Effective(),
|
||||
AllowedSkills: cleanStringList(profile.Skills.Allow),
|
||||
AllowedTools: cleanStringList(profile.Tools.Allow),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (c *Config) ValidateTurnProfile() error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return validateTurnProfile(c.Agents.Defaults.TurnProfile)
|
||||
}
|
||||
|
||||
func validateTurnProfile(profile TurnProfileConfig) error {
|
||||
if !profile.Enabled {
|
||||
return nil
|
||||
}
|
||||
if err := validateTurnProfileBlock("history", profile.History, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTurnProfileBlock("system_prompt", profile.SystemPrompt, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTurnProfileBlock("skills", profile.Skills, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTurnProfileBlock("tools", profile.Tools, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTurnProfileBlock(field string, block TurnProfileBlock, allowCustom bool) error {
|
||||
mode := block.Mode.Effective()
|
||||
switch mode {
|
||||
case TurnProfileModeDefault, TurnProfileModeOff:
|
||||
return nil
|
||||
case TurnProfileModeCustom:
|
||||
if allowCustom {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("turn_profile.%s.mode custom is not supported in this version", field)
|
||||
default:
|
||||
return fmt.Errorf("turn_profile.%s.mode has unsupported mode %q", field, block.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanStringList(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user