From 2992eccbf03e54258a6889cf19a5a4fe6255895f Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Fri, 22 May 2026 10:06:40 +0800 Subject: [PATCH] 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 --- docs/guides/configuration.fr.md | 44 + docs/guides/configuration.ja.md | 44 + docs/guides/configuration.md | 44 + docs/guides/configuration.pt-br.md | 44 + docs/guides/configuration.vi.md | 44 + docs/guides/configuration.zh.md | 44 + pkg/agent/agent.go | 32 +- pkg/agent/agent_message.go | 5 + pkg/agent/agent_utils.go | 6 + pkg/agent/context.go | 367 +++-- pkg/agent/pipeline_execute.go | 44 +- pkg/agent/pipeline_finalize.go | 2 +- pkg/agent/pipeline_llm.go | 13 +- pkg/agent/pipeline_setup.go | 6 +- pkg/agent/prompt.go | 7 + pkg/agent/prompt_contributors.go | 56 +- pkg/agent/prompt_test.go | 174 +++ pkg/agent/prompt_turn.go | 90 +- pkg/agent/subturn.go | 4 + pkg/agent/turn_coord.go | 41 +- pkg/agent/turn_coord_test.go | 4 + pkg/agent/turn_profile_policy.go | 131 ++ pkg/agent/turn_profile_test.go | 1175 +++++++++++++++++ pkg/agent/turn_state.go | 8 +- pkg/channels/pico/pico_test.go | 33 + pkg/config/config.go | 4 + pkg/config/config_test.go | 124 ++ pkg/config/turn_profile.go | 133 ++ web/backend/api/config.go | 4 + web/backend/api/config_test.go | 243 +++- .../src/components/chat/chat-composer.tsx | 23 +- .../src/components/chat/chat-page.tsx | 2 +- .../src/components/config/config-page.tsx | 45 +- .../src/components/config/config-sections.tsx | 148 +++ .../src/components/config/form-model.ts | 63 + web/frontend/src/features/chat/controller.ts | 10 +- web/frontend/src/i18n/locales/en.json | 17 + web/frontend/src/i18n/locales/pt-br.json | 17 + web/frontend/src/i18n/locales/zh.json | 17 + 39 files changed, 3150 insertions(+), 162 deletions(-) create mode 100644 pkg/agent/turn_profile_policy.go create mode 100644 pkg/agent/turn_profile_test.go create mode 100644 pkg/config/turn_profile.go diff --git a/docs/guides/configuration.fr.md b/docs/guides/configuration.fr.md index a3ce6cb06..dcacc1874 100644 --- a/docs/guides/configuration.fr.md +++ b/docs/guides/configuration.fr.md @@ -66,6 +66,50 @@ PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/. > **Remarque :** Les modifications apportées à `AGENT.md`, `SOUL.md`, `USER.md` et `memory/MEMORY.md` sont détectées automatiquement au moment de l'exécution via le suivi de la date de modification (mtime). Il n'est **pas nécessaire de redémarrer le gateway** après avoir modifié ces fichiers — l'agent charge le nouveau contenu à la prochaine requête. +### Politique de contexte de requête + +`turn_profile` est une politique facultative sous `agents.defaults.turn_profile` pour contrôler le contexte chargé par chaque nouveau tour : historique, prompt système, prompts de skills et outils autorisés. Sans cette configuration, ou avec `"enabled": false`, PicoClaw garde son comportement normal. Avec `"enabled": true`, la politique ci-dessous s'applique à chaque nouveau tour. + +Tous les blocs utilisent les mêmes valeurs de `mode` : + +| Mode | Signification | +| --- | --- | +| `default` | Garde le comportement normal de PicoClaw. Un bloc absent ou sans `mode` vaut `default`. | +| `off` | Désactive ce bloc pour le tour. | +| `custom` | Utilise une liste d'autorisation. Dans cette version, `custom` est pris en charge seulement pour `skills` et `tools`; l'utiliser pour `history` ou `system_prompt` produit une erreur de validation. | + +Blocs disponibles : + +| Bloc | Ce qu'il contrôle | +| --- | --- | +| `history` | Lecture de l'historique et du résumé, écriture des messages utilisateur/assistant/outil, ingestion de contexte, compression et résumé. | +| `system_prompt` | Injection de l'identité PicoClaw, des instructions de l'espace de travail, de la mémoire, du contexte d'exécution et du résumé. Les prompts système externes restent autorisés quand ce bloc est `off`. | +| `skills` | Chargement du catalogue de skills et du contenu des skills actifs. `custom.allow` ne garde que les noms listés. | +| `tools` | Outils exposés au modèle et autorisés à l'exécution. `custom.allow` ne garde que les outils enregistrés et listés. | + +Quand `system_prompt.mode` vaut `off`, que des outils restent visibles et qu'aucun prompt système externe n'est fourni, PicoClaw réutilise sa règle existante d'utilisation des outils comme prompt minimal de secours. Si `tools.mode` vaut `off`, ce prompt de secours n'est pas ajouté. + +Exemple de contexte propre avec outils web : + +```json +{ + "agents": { + "defaults": { + "turn_profile": { + "enabled": true, + "history": { "mode": "off" }, + "system_prompt": { "mode": "off" }, + "skills": { "mode": "off" }, + "tools": { + "mode": "custom", + "allow": ["web_search", "web_fetch"] + } + } + } + } +} +``` + ### Sources de Compétences Par défaut, les compétences sont chargées depuis : diff --git a/docs/guides/configuration.ja.md b/docs/guides/configuration.ja.md index 38d85bc6c..7dd676447 100644 --- a/docs/guides/configuration.ja.md +++ b/docs/guides/configuration.ja.md @@ -67,6 +67,50 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw > **注意:** `AGENT.md`、`SOUL.md`、`USER.md` および `memory/MEMORY.md` への変更は、ファイル更新時刻(mtime)の追跡により実行時に自動検出されます。これらのファイルを編集した後に **gateway を再起動する必要はありません** — Agent は次のリクエスト時に最新の内容を自動的に読み込みます。 +### リクエストコンテキストポリシー + +`turn_profile` は `agents.defaults.turn_profile` に置く任意のリクエストコンテキストポリシーです。各ターンに履歴、system prompt、skill prompt、許可ツールを含めるかどうかを制御します。未設定、または `"enabled": false` の場合、PicoClaw は通常動作のままです。`"enabled": true` にすると、このポリシーが各新規ターンに適用されます。 + +各ブロックは同じ `mode` を使います。 + +| Mode | 意味 | +| --- | --- | +| `default` | PicoClaw の通常動作を維持します。ブロックまたは `mode` が省略された場合も `default` です。 | +| `off` | そのブロックを無効にします。 | +| `custom` | 許可リストを使います。このバージョンでは `skills` と `tools` のみ対応し、`history` や `system_prompt` で使うと検証エラーになります。 | + +ブロックの意味: + +| ブロック | 制御する内容 | +| --- | --- | +| `history` | 履歴と要約の読み込み、ユーザー/アシスタント/ツールメッセージの保存、コンテキスト取り込み、圧縮と要約。 | +| `system_prompt` | PicoClaw の既定の identity、ワークスペース指示、メモリ、実行時コンテキスト、要約の注入。`off` でも外部 system prompt は利用できます。 | +| `skills` | Skill カタログと active skill のプロンプト内容。`custom.allow` は列挙した skill 名だけを残します。 | +| `tools` | モデルに見せ、実行を許可するツール。`custom.allow` は登録済みで列挙されたツール名だけを残します。 | + +`system_prompt.mode` が `off` で、ツールが表示され、外部 system prompt がない場合、PicoClaw は既存のツール使用ルールを最小のフォールバックプロンプトとして再利用します。`tools.mode` が `off` の場合、このフォールバックは追加されません。 + +Web ツールだけを残すクリーンなコンテキスト例: + +```json +{ + "agents": { + "defaults": { + "turn_profile": { + "enabled": true, + "history": { "mode": "off" }, + "system_prompt": { "mode": "off" }, + "skills": { "mode": "off" }, + "tools": { + "mode": "custom", + "allow": ["web_search", "web_fetch"] + } + } + } + } +} +``` + ### スキルソース デフォルトでは、スキルは以下の順序で読み込まれます: diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 83a62fd0c..ef4802a78 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -99,6 +99,50 @@ The `evolution` block controls PicoClaw's self-evolution runtime. When enabled, Use `observe` first if you want to inspect learning records without generating skill changes. Use `draft` when you want PicoClaw to prepare reviewable improvements. Use `apply` only when you are comfortable letting accepted drafts update workspace skills. +### Request Context Policy + +`turn_profile` is an optional request context policy under `agents.defaults.turn_profile`. Leave it unset or set `"enabled": false` to keep PicoClaw's normal behavior. When `"enabled": true`, the same policy applies to every new turn. + +Each block uses the same `mode` values: + +| Mode | Meaning | +| --- | --- | +| `default` | Keep PicoClaw's normal behavior for that block. Missing blocks and missing `mode` fields are treated as `default`. | +| `off` | Disable that block for the turn. | +| `custom` | Use an allow list. In this version, `custom` is supported only for `skills` and `tools`; using it for `history` or `system_prompt` is a validation error. | + +Profile blocks: + +| Block | What it controls | +| --- | --- | +| `history` | Whether the turn reads prior session history and summary, writes user/assistant/tool messages, ingests context, and runs compaction or summarization. | +| `system_prompt` | Whether PicoClaw injects its default identity, workspace instructions, memory, runtime context, and summary. External request system prompts are still allowed when this is `off`. | +| `skills` | Whether the skill catalog and active skill prompt content are loaded. `custom.allow` keeps only the listed skill names in prompt context. | +| `tools` | Which callable tools are exposed to the model and allowed at execution time. `custom.allow` keeps only listed registered tool names. | + +When `system_prompt.mode` is `off`, tools are still visible, and no external system prompt is supplied, PicoClaw uses its existing tool-use rule as the minimal fallback prompt. If `tools.mode` is `off`, no fallback prompt is added. + +Example clean web policy: + +```json +{ + "agents": { + "defaults": { + "turn_profile": { + "enabled": true, + "history": { "mode": "off" }, + "system_prompt": { "mode": "off" }, + "skills": { "mode": "off" }, + "tools": { + "mode": "custom", + "allow": ["web_search", "web_fetch"] + } + } + } + } +} +``` + ### Web launcher dashboard **picoclaw-launcher** serves a browser UI that requires password sign-in first. On first run, open `/launcher-setup` to create the dashboard password. Later manual sign-ins use `/launcher-login`. diff --git a/docs/guides/configuration.pt-br.md b/docs/guides/configuration.pt-br.md index b1c95f5ba..bb5d14131 100644 --- a/docs/guides/configuration.pt-br.md +++ b/docs/guides/configuration.pt-br.md @@ -67,6 +67,50 @@ O PicoClaw armazena dados no seu workspace configurado (padrão: `~/.picoclaw/wo > **Nota:** Alterações em `AGENT.md`, `SOUL.md`, `USER.md` e `memory/MEMORY.md` são detectadas automaticamente em tempo de execução via rastreamento de data de modificação (mtime). **Não é necessário reiniciar o gateway** após editar esses arquivos — o agente carrega o novo conteúdo na próxima requisição. +### Política de contexto da requisição + +`turn_profile` é uma política opcional em `agents.defaults.turn_profile` para controlar qual contexto cada novo turno carrega: histórico, prompt de sistema, prompts de skills e ferramentas permitidas. Sem essa configuração, ou com `"enabled": false`, o PicoClaw mantém o comportamento normal. Com `"enabled": true`, a política abaixo se aplica a cada novo turno. + +Todos os blocos usam os mesmos valores de `mode`: + +| Mode | Significado | +| --- | --- | +| `default` | Mantém o comportamento normal do PicoClaw. Blocos ausentes ou sem `mode` são tratados como `default`. | +| `off` | Desativa esse bloco para o turno. | +| `custom` | Usa uma lista de permissão. Nesta versão, `custom` só é suportado para `skills` e `tools`; usá-lo em `history` ou `system_prompt` gera erro de validação. | + +Blocos disponíveis: + +| Bloco | O que controla | +| --- | --- | +| `history` | Leitura de histórico e resumo, gravação de mensagens de usuário/assistente/ferramenta, ingestão de contexto, compactação e resumo. | +| `system_prompt` | Injeção da identidade padrão do PicoClaw, instruções do workspace, memória, contexto de execução e resumo. Prompts de sistema externos ainda são permitidos quando este bloco está `off`. | +| `skills` | Catálogo de skills e conteúdo de skills ativas no prompt. `custom.allow` mantém apenas os nomes listados. | +| `tools` | Ferramentas visíveis ao modelo e permitidas na execução. `custom.allow` mantém apenas ferramentas registradas e listadas. | + +Quando `system_prompt.mode` é `off`, ferramentas continuam visíveis e nenhum prompt de sistema externo é fornecido, o PicoClaw reutiliza sua regra existente de uso de ferramentas como prompt mínimo de fallback. Se `tools.mode` é `off`, esse fallback não é adicionado. + +Exemplo de contexto limpo com ferramentas web: + +```json +{ + "agents": { + "defaults": { + "turn_profile": { + "enabled": true, + "history": { "mode": "off" }, + "system_prompt": { "mode": "off" }, + "skills": { "mode": "off" }, + "tools": { + "mode": "custom", + "allow": ["web_search", "web_fetch"] + } + } + } + } +} +``` + ### Fontes de Skills Por padrão, as skills são carregadas de: diff --git a/docs/guides/configuration.vi.md b/docs/guides/configuration.vi.md index 79ab4723c..516ebe9fe 100644 --- a/docs/guides/configuration.vi.md +++ b/docs/guides/configuration.vi.md @@ -67,6 +67,50 @@ PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: > **Lưu ý:** Các thay đổi đối với `AGENT.md`, `SOUL.md`, `USER.md` và `memory/MEMORY.md` được tự động phát hiện trong thời gian chạy thông qua theo dõi thời gian sửa đổi file (mtime). **Không cần khởi động lại gateway** sau khi chỉnh sửa các file này — agent sẽ tải nội dung mới vào yêu cầu tiếp theo. +### Chính sách ngữ cảnh request + +`turn_profile` là chính sách tùy chọn trong `agents.defaults.turn_profile` để kiểm soát ngữ cảnh mỗi turn mới mang theo: lịch sử, system prompt, prompt skills và các tool được phép gọi. Nếu không cấu hình, hoặc đặt `"enabled": false`, PicoClaw giữ nguyên hành vi mặc định. Khi đặt `"enabled": true`, chính sách bên dưới áp dụng cho mỗi turn mới. + +Mỗi block dùng chung các giá trị `mode`: + +| Mode | Ý nghĩa | +| --- | --- | +| `default` | Giữ hành vi bình thường của PicoClaw. Block bị thiếu hoặc thiếu `mode` đều được xem là `default`. | +| `off` | Tắt block đó cho turn. | +| `custom` | Dùng danh sách cho phép. Phiên bản này chỉ hỗ trợ `custom` cho `skills` và `tools`; dùng cho `history` hoặc `system_prompt` sẽ lỗi validate. | + +Các block: + +| Block | Nội dung kiểm soát | +| --- | --- | +| `history` | Đọc lịch sử và tóm tắt, ghi tin nhắn user/assistant/tool, nạp context, compact và summarize. | +| `system_prompt` | Chèn identity mặc định của PicoClaw, chỉ dẫn workspace, memory, runtime context và summary. System prompt từ request bên ngoài vẫn được dùng khi block này `off`. | +| `skills` | Catalog skills và nội dung active skill trong prompt. `custom.allow` chỉ giữ các tên skill được liệt kê. | +| `tools` | Công cụ hiển thị cho model và được phép thực thi. `custom.allow` chỉ giữ các tool đã đăng ký và được liệt kê. | + +Khi `system_prompt.mode` là `off`, tools vẫn hiển thị và không có system prompt bên ngoài, PicoClaw dùng lại quy tắc dùng tool hiện có làm prompt fallback tối thiểu. Nếu `tools.mode` là `off`, fallback này không được thêm. + +Ví dụ ngữ cảnh sạch chỉ giữ tool web: + +```json +{ + "agents": { + "defaults": { + "turn_profile": { + "enabled": true, + "history": { "mode": "off" }, + "system_prompt": { "mode": "off" }, + "skills": { "mode": "off" }, + "tools": { + "mode": "custom", + "allow": ["web_search", "web_fetch"] + } + } + } + } +} +``` + ### Nguồn Skill Mặc định, skill được tải từ: diff --git a/docs/guides/configuration.zh.md b/docs/guides/configuration.zh.md index 790dd6425..1d04a0d48 100644 --- a/docs/guides/configuration.zh.md +++ b/docs/guides/configuration.zh.md @@ -97,6 +97,50 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work 如果你只想先检查学习记录,建议从 `observe` 开始。需要生成可审查改进时使用 `draft`。只有在你接受让已通过的草稿更新工作区技能时,才使用 `apply`。 +### 请求上下文策略 + +`turn_profile` 是 `agents.defaults.turn_profile` 下的可选请求上下文策略,用来控制每个新回合是否带入历史、系统提示、技能提示,以及允许调用哪些工具。不写该配置或设置 `"enabled": false` 时,PicoClaw 完全保持原逻辑;设置 `"enabled": true` 后,下面的策略会应用到每个新回合。 + +所有块都使用同一组 `mode`: + +| Mode | 含义 | +| --- | --- | +| `default` | 保持 PicoClaw 原逻辑。块缺失或 `mode` 缺失都按 `default` 处理。 | +| `off` | 关闭该块。 | +| `custom` | 使用允许列表。本版本仅支持 `skills` 和 `tools`,在 `history` 或 `system_prompt` 中使用会触发配置校验错误。 | + +各块含义: + +| 块 | 控制内容 | +| --- | --- | +| `history` | 是否读取历史和摘要、写入用户/助手/工具消息、写入 context manager,以及是否压缩或总结本轮会话。 | +| `system_prompt` | 是否注入 PicoClaw 默认身份、工作区指令、记忆、运行时上下文和摘要。关闭后仍可使用外部传入的 system prompt。 | +| `skills` | 是否加载技能目录和 active skill 提示。`custom.allow` 只保留列出的技能名。 | +| `tools` | 暴露给模型并允许执行的工具。`custom.allow` 只保留已注册且列出的工具名。 | + +当 `system_prompt.mode` 为 `off`、工具仍可见且没有外部 system prompt 时,PicoClaw 会复用现有的工具使用规则作为最小兜底提示。如果 `tools.mode` 为 `off`,则不会添加兜底提示。 + +只保留 Web 工具的干净上下文示例: + +```json +{ + "agents": { + "defaults": { + "turn_profile": { + "enabled": true, + "history": { "mode": "off" }, + "system_prompt": { "mode": "off" }, + "skills": { "mode": "off" }, + "tools": { + "mode": "custom", + "allow": ["web_search", "web_fetch"] + } + } + } + } +} +``` + ### Web 启动器控制台 用 **picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`。 diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 0392829f9..90745e5d7 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -79,17 +79,18 @@ type AgentLoop struct { // processOptions configures how a message is processed type processOptions struct { - Dispatch DispatchRequest // Normalized routed request boundary for this turn - SessionKey string // Session identifier for history/context - SessionAliases []string // Compatibility aliases for the session key - Channel string // Target channel for tool execution - ChatID string // Target chat ID for tool execution - MessageID string // Current inbound platform message ID - ReplyToMessageID string // Current inbound reply target message ID - SenderID string // Current sender ID for dynamic context - SenderDisplayName string // Current sender display name for dynamic context - UserMessage string // User message content (may include prefix) - ForcedSkills []string // Skills explicitly requested for this message + Dispatch DispatchRequest // Normalized routed request boundary for this turn + SessionKey string // Session identifier for history/context + SessionAliases []string // Compatibility aliases for the session key + Channel string // Target channel for tool execution + ChatID string // Target chat ID for tool execution + MessageID string // Current inbound platform message ID + ReplyToMessageID string // Current inbound reply target message ID + SenderID string // Current sender ID for dynamic context + SenderDisplayName string // Current sender display name for dynamic context + UserMessage string // User message content (may include prefix) + ForcedSkills []string // Skills explicitly requested for this message + TurnProfile config.EffectiveTurnProfile SystemPromptOverride string // Override the default system prompt (Used by SubTurns) Media []string // media:// refs from inbound message InitialSteeringMessages []providers.Message // Steering messages from refactor/agent @@ -534,17 +535,22 @@ func (al *AgentLoop) runAgentLoop( opts processOptions, ) (string, error) { opts = normalizeProcessOptions(opts) + var err error + opts, err = resolveTurnProfileOptions(al.GetConfig(), opts) + if err != nil { + return "", err + } // Record last channel for heartbeat notifications (skip internal channels and cli) if opts.Dispatch.Channel() != "" && opts.Dispatch.ChatID() != "" && !constants.IsInternalChannel(opts.Dispatch.Channel()) { channelKey := fmt.Sprintf("%s:%s", opts.Dispatch.Channel(), opts.Dispatch.ChatID()) - if err := al.RecordLastChannel(channelKey); err != nil { + if recordErr := al.RecordLastChannel(channelKey); recordErr != nil { logger.WarnCF( "agent", "Failed to record last channel", - map[string]any{"error": err.Error()}, + map[string]any{"error": recordErr.Error()}, ) } } diff --git a/pkg/agent/agent_message.go b/pkg/agent/agent_message.go index 4d2886a80..419664ed4 100644 --- a/pkg/agent/agent_message.go +++ b/pkg/agent/agent_message.go @@ -193,6 +193,11 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) SendResponse: false, AllowInterimPicoPublish: true, } + var err error + opts, err = resolveTurnProfileOptions(al.GetConfig(), opts) + if err != nil { + return "", err + } // context-dependent commands check their own Runtime fields and report // "unavailable" when the required capability is nil. diff --git a/pkg/agent/agent_utils.go b/pkg/agent/agent_utils.go index 8c638deb8..effa2b19e 100644 --- a/pkg/agent/agent_utils.go +++ b/pkg/agent/agent_utils.go @@ -481,6 +481,9 @@ func activeSkillNames(agent *AgentInstance, opts processOptions) []string { if agent == nil { return nil } + if turnProfileSkillsOff(opts.TurnProfile) { + return nil + } combined := make([]string, 0, len(agent.SkillsFilter)+len(opts.ForcedSkills)) combined = append(combined, agent.SkillsFilter...) @@ -509,6 +512,9 @@ func activeSkillNames(agent *AgentInstance, opts processOptions) []string { resolved = append(resolved, name) } + if turnProfileCustomSkills(opts.TurnProfile) { + return filterNamesByTurnProfile(resolved, opts.TurnProfile.AllowedSkills) + } return resolved } diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 87bdd6b41..01e9ec0b2 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -54,9 +54,13 @@ func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuil useBM25: useBM25, useRegex: useRegex, }); err != nil { - logger.WarnCF("agent", "Failed to register tool discovery prompt contributor", map[string]any{ - "error": err.Error(), - }) + logger.WarnCF( + "agent", + "Failed to register tool discovery prompt contributor", + map[string]any{ + "error": err.Error(), + }, + ) } } return cb @@ -77,9 +81,13 @@ func (cb *ContextBuilder) WithAgentDiscovery( agentID: agentID, discover: discover, }); err != nil { - logger.WarnCF("agent", "Failed to register agent discovery prompt contributor", map[string]any{ - "error": err.Error(), - }) + logger.WarnCF( + "agent", + "Failed to register agent discovery prompt contributor", + map[string]any{ + "error": err.Error(), + }, + ) } } return cb @@ -130,9 +138,34 @@ func (cb *ContextBuilder) promptRegistryOrDefault() *PromptRegistry { return cb.promptRegistry } -func (cb *ContextBuilder) getIdentity() string { +func (cb *ContextBuilder) getIdentity(includeToolUseRule bool) string { workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) version := config.FormatVersion() + rules := []string{} + if includeToolUseRule { + rules = append(rules, toolUseSystemPromptRule()) + } + accuracyRule := "**Be helpful and accurate** - Briefly explain what you're doing." + if includeToolUseRule { + accuracyRule = "**Be helpful and accurate** - When using tools, briefly explain what you're doing." + } + rules = append( + rules, + accuracyRule, + "**Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.", + ) + if includeToolUseRule { + rules = append( + rules, + fmt.Sprintf( + "**Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md", + workspacePath, + ), + ) + } + for i, rule := range rules { + rules[i] = fmt.Sprintf("%d. %s", i+1, rule) + } return fmt.Sprintf( `# picoclaw 🦞 (%s) @@ -147,14 +180,15 @@ Your workspace is at: %s ## Important Rules -1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it. - -2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. - -3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md - -4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`, - version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath) +%s +`, + version, + workspacePath, + workspacePath, + workspacePath, + workspacePath, + strings.Join(rules, "\n\n"), + ) } func formatToolDiscoveryRule(useBM25, useRegex bool) string { @@ -181,6 +215,20 @@ func (cb *ContextBuilder) BuildSystemPrompt() string { } func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart { + return cb.buildSystemPromptParts(systemPromptBuildOptions{ + IncludeSkillCatalog: true, + IncludeToolUseRule: true, + }) +} + +type systemPromptBuildOptions struct { + IncludeSkillCatalog bool + IncludeToolUseRule bool + AllowedSkills []string + AllowedTools []string +} + +func (cb *ContextBuilder) buildSystemPromptParts(opts systemPromptBuildOptions) []PromptPart { stack := NewPromptStack(cb.promptRegistryOrDefault()) add := func(part PromptPart) { if err := stack.Add(part); err != nil { @@ -201,7 +249,7 @@ func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart { Slot: PromptSlotIdentity, Source: PromptSource{ID: PromptSourceKernel, Name: "identity"}, Title: "picoclaw identity", - Content: cb.getIdentity(), + Content: cb.getIdentity(opts.IncludeToolUseRule), Stable: true, Cache: PromptCacheEphemeral, }) @@ -222,8 +270,19 @@ func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart { } // Skills - show summary, AI can read full content with read_file tool - skillsSummary := cb.skillsLoader.BuildSkillsSummary() + skillsSummary := "" + if opts.IncludeSkillCatalog { + skillsSummary = cb.buildSkillsSummary(opts.AllowedSkills) + } if skillsSummary != "" { + skillIntro := "The following skills extend your capabilities." + readFileAllowed := promptAllowsTool( + PromptBuildRequest{AllowedTools: opts.AllowedTools}, + "read_file", + ) + if opts.IncludeToolUseRule && readFileAllowed { + skillIntro += " To use a skill, read its SKILL.md file using the read_file tool." + } add(PromptPart{ ID: "capability.skill_catalog", Layer: PromptLayerCapability, @@ -232,9 +291,9 @@ func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart { Title: "skill catalog", Content: fmt.Sprintf(`# Skills -The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. +%s -%s`, skillsSummary), +%s`, skillIntro, skillsSummary), Stable: true, Cache: PromptCacheEphemeral, }) @@ -319,6 +378,96 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string { return prompt } +func (cb *ContextBuilder) buildSystemPromptForRequest( + req PromptBuildRequest, +) (string, []providers.ContentBlock) { + if req.SuppressDefaultSystemPrompt { + return "", nil + } + + useDefaultCache := !req.SuppressSkillContext && + !req.SuppressToolUseRule && + len(req.AllowedSkills) == 0 && + len(req.AllowedTools) == 0 + if useDefaultCache { + staticPrompt := cb.BuildSystemPromptWithCache() + return staticPrompt, []providers.ContentBlock{ + promptContentBlock(PromptPart{ + ID: "kernel.static", + Layer: PromptLayerKernel, + Slot: PromptSlotIdentity, + Source: PromptSource{ID: PromptSourceKernel, Name: "static"}, + Content: staticPrompt, + }, &providers.CacheControl{Type: "ephemeral"}), + } + } + + parts := cb.buildSystemPromptParts(systemPromptBuildOptions{ + IncludeSkillCatalog: !req.SuppressSkillContext, + IncludeToolUseRule: !req.SuppressToolUseRule, + AllowedSkills: req.AllowedSkills, + AllowedTools: req.AllowedTools, + }) + staticPrompt := renderPromptPartsLegacy(parts) + blocks := make([]providers.ContentBlock, 0, len(parts)) + for _, part := range parts { + if strings.TrimSpace(part.Content) == "" { + continue + } + blocks = append(blocks, promptContentBlock(part, cacheControlForPromptPart(part))) + } + return staticPrompt, blocks +} + +func (cb *ContextBuilder) buildSkillsSummary(allowed []string) string { + if cb.skillsLoader == nil { + return "" + } + if len(allowed) == 0 { + return cb.skillsLoader.BuildSkillsSummary() + } + allowedSet := cleanAllowedSet(allowed) + if len(allowedSet) == 0 { + return "" + } + + var lines []string + lines = append(lines, "") + for _, s := range cb.skillsLoader.ListSkills() { + if _, ok := allowedSet[strings.ToLower(strings.TrimSpace(s.Name))]; !ok { + continue + } + lines = append(lines, " ") + lines = append(lines, fmt.Sprintf(" %s", xmlEscapeForPrompt(s.Name))) + lines = append( + lines, + fmt.Sprintf(" %s", xmlEscapeForPrompt(s.Description)), + ) + lines = append( + lines, + fmt.Sprintf(" %s", xmlEscapeForPrompt(s.Path)), + ) + lines = append(lines, fmt.Sprintf(" %s", xmlEscapeForPrompt(s.Source))) + lines = append(lines, " ") + } + if len(lines) == 1 { + return "" + } + lines = append(lines, "") + return strings.Join(lines, "\n") +} + +func xmlEscapeForPrompt(s string) string { + replacer := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + "\"", """, + "'", "'", + ) + return replacer.Replace(s) +} + // EstimateSystemTokens estimates the token count of the full system message // that would be sent to the LLM, mirroring the composition logic in BuildMessages. // It includes: static prompt, dynamic context, active skills, and summary with @@ -687,19 +836,18 @@ func (cb *ContextBuilder) BuildMessages( func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []providers.Message { messages := []providers.Message{} - // The static part (identity, bootstrap, skills, memory) is cached locally to - // avoid repeated file I/O and string building on every call (fixes issue #607). - // Dynamic parts (time, session, summary) are appended per request. + // The default static part (identity, bootstrap, skills, memory) is cached + // locally to avoid repeated file I/O and string building on every call + // (fixes issue #607). Profile-customized static prompts are built on demand. + // Dynamic parts (time, session, summary) are appended per request unless the + // profile suppresses PicoClaw system context. // Everything is sent as a single system message for provider compatibility: // - Anthropic adapter extracts messages[0] (Role=="system") and maps its content // to the top-level "system" parameter in the Messages API request. A single // contiguous system block makes this extraction straightforward. // - Codex maps only the first system message to its instructions field. // - OpenAI-compat passes messages through as-is. - staticPrompt := cb.BuildSystemPromptWithCache() - - // Build short dynamic context (time, runtime, session) — changes per request - dynamicCtx := cb.buildDynamicContext(req.Channel, req.ChatID, req.SenderID, req.SenderDisplayName) + staticPrompt, contentBlocks := cb.buildSystemPromptForRequest(req) // Compose a single system message: static (cached) + dynamic + optional summary. // Keeping all system content in one message ensures every provider adapter can @@ -710,26 +858,27 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov // cache-aware adapters (Anthropic) can set per-block cache_control. // The static block is marked "ephemeral" — its prefix hash is stable // across requests, enabling LLM-side KV cache reuse. - stringParts := []string{staticPrompt} - - contentBlocks := []providers.ContentBlock{ - promptContentBlock(PromptPart{ - ID: "kernel.static", - Layer: PromptLayerKernel, - Slot: PromptSlotIdentity, - Source: PromptSource{ID: PromptSourceKernel, Name: "static"}, - Content: staticPrompt, - }, &providers.CacheControl{Type: "ephemeral"}), + var stringParts []string + if strings.TrimSpace(staticPrompt) != "" { + stringParts = append(stringParts, staticPrompt) } promptParts := append([]PromptPart(nil), req.Overlays...) - promptParts = append(promptParts, cb.buildActiveSkillsPromptParts(req.ActiveSkills)...) - if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), req); err != nil { - logger.WarnCF("agent", "Prompt contributor collection failed", map[string]any{ - "error": err.Error(), - }) - } else { - promptParts = append(promptParts, contributedParts...) + if !req.SuppressDefaultSystemPrompt && !req.SuppressSkillContext { + activeSkills := append([]string(nil), req.ActiveSkills...) + if len(req.AllowedSkills) > 0 { + activeSkills = filterNamesByTurnProfile(activeSkills, req.AllowedSkills) + } + promptParts = append(promptParts, cb.buildActiveSkillsPromptParts(activeSkills)...) + } + if !req.SuppressDefaultSystemPrompt { + if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), req); err != nil { + logger.WarnCF("agent", "Prompt contributor collection failed", map[string]any{ + "error": err.Error(), + }) + } else { + promptParts = append(promptParts, contributedParts...) + } } if len(promptParts) > 0 { @@ -752,35 +901,62 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov } } - runtimePart := PromptPart{ - ID: "context.runtime", - Layer: PromptLayerContext, - Slot: PromptSlotRuntime, - Source: PromptSource{ID: PromptSourceRuntime, Name: "runtime"}, - Title: "runtime context", - Content: dynamicCtx, - Stable: false, - Cache: PromptCacheNone, - } - stringParts = append(stringParts, dynamicCtx) - contentBlocks = append(contentBlocks, promptContentBlock(runtimePart, nil)) - - if req.Summary != "" { - summaryPart := PromptPart{ - ID: "context.summary", - Layer: PromptLayerContext, - Slot: PromptSlotSummary, - Source: PromptSource{ID: PromptSourceSummary, Name: "context.summary"}, - Title: "context summary", - Content: fmt.Sprintf( - "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+ - "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s", - req.Summary), - Stable: false, - Cache: PromptCacheNone, + dynamicChars := 0 + if !req.SuppressDefaultSystemPrompt { + // Build short dynamic context (time, runtime, session) — changes per request + dynamicCtx := cb.buildDynamicContext( + req.Channel, + req.ChatID, + req.SenderID, + req.SenderDisplayName, + ) + dynamicChars = len(dynamicCtx) + runtimePart := PromptPart{ + ID: "context.runtime", + Layer: PromptLayerContext, + Slot: PromptSlotRuntime, + Source: PromptSource{ID: PromptSourceRuntime, Name: "runtime"}, + Title: "runtime context", + Content: dynamicCtx, + Stable: false, + Cache: PromptCacheNone, } - stringParts = append(stringParts, summaryPart.Content) - contentBlocks = append(contentBlocks, promptContentBlock(summaryPart, nil)) + stringParts = append(stringParts, dynamicCtx) + contentBlocks = append(contentBlocks, promptContentBlock(runtimePart, nil)) + + if req.Summary != "" { + summaryPart := PromptPart{ + ID: "context.summary", + Layer: PromptLayerContext, + Slot: PromptSlotSummary, + Source: PromptSource{ID: PromptSourceSummary, Name: "context.summary"}, + Title: "context summary", + Content: fmt.Sprintf( + "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+ + "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s", + req.Summary, + ), + Stable: false, + Cache: PromptCacheNone, + } + stringParts = append(stringParts, summaryPart.Content) + contentBlocks = append(contentBlocks, promptContentBlock(summaryPart, nil)) + } + } + + if len(stringParts) == 0 && req.ToolUseFallback { + fallbackPart := PromptPart{ + ID: "kernel.tool_use_fallback", + Layer: PromptLayerKernel, + Slot: PromptSlotIdentity, + Source: PromptSource{ID: PromptSourceKernel, Name: "tool_use_fallback"}, + Title: "tool use fallback", + Content: toolUseSystemPromptRule(), + Stable: true, + Cache: PromptCacheEphemeral, + } + stringParts = append(stringParts, fallbackPart.Content) + contentBlocks = append(contentBlocks, promptContentBlock(fallbackPart, nil)) } fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n") @@ -795,7 +971,7 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov logger.DebugCF("agent", "System prompt built", map[string]any{ "static_chars": len(staticPrompt), - "dynamic_chars": len(dynamicCtx), + "dynamic_chars": dynamicChars, "total_chars": len(fullSystemPrompt), "has_summary": req.Summary != "", "overlays": len(req.Overlays), @@ -814,11 +990,13 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov // Single system message containing all context — compatible with all providers. // SystemParts enables cache-aware adapters to set per-block cache_control; // Content is the concatenated fallback for adapters that don't read SystemParts. - messages = append(messages, providers.Message{ - Role: "system", - Content: fullSystemPrompt, - SystemParts: contentBlocks, - }) + if strings.TrimSpace(fullSystemPrompt) != "" { + messages = append(messages, providers.Message{ + Role: "system", + Content: fullSystemPrompt, + SystemParts: contentBlocks, + }) + } // Add conversation history messages = append(messages, history...) @@ -829,6 +1007,9 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov if strings.TrimSpace(req.CurrentMessage) != "" || len(req.Media) > 0 { messages = append(messages, userPromptMessage(req.CurrentMessage, req.Media)) } + if len(messages) == 0 { + messages = append(messages, userPromptMessage("", nil)) + } return messages } @@ -933,7 +1114,11 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message break } if next.ToolCallID == "" { - logger.DebugCF("agent", "Dropping tool result without tool_call_id", map[string]any{}) + logger.DebugCF( + "agent", + "Dropping tool result without tool_call_id", + map[string]any{}, + ) continue } if _, ok := expected[next.ToolCallID]; !ok { @@ -943,9 +1128,13 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message continue } if seenInBlock[next.ToolCallID] { - logger.DebugCF("agent", "Dropping duplicate tool result in tool block", map[string]any{ - "tool_call_id": next.ToolCallID, - }) + logger.DebugCF( + "agent", + "Dropping duplicate tool result in tool block", + map[string]any{ + "tool_call_id": next.ToolCallID, + }, + ) continue } seenInBlock[next.ToolCallID] = true @@ -955,7 +1144,11 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message allFound := !invalidToolCallID if invalidToolCallID { - logger.DebugCF("agent", "Dropping assistant message with empty tool_call_id", map[string]any{}) + logger.DebugCF( + "agent", + "Dropping assistant message with empty tool_call_id", + map[string]any{}, + ) } for toolCallID, found := range expected { if !found { @@ -985,9 +1178,13 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message } if msg.Role == "tool" { - logger.DebugCF("agent", "Dropping orphaned tool message after validation", map[string]any{ - "tool_call_id": msg.ToolCallID, - }) + logger.DebugCF( + "agent", + "Dropping orphaned tool message after validation", + map[string]any{ + "tool_call_id": msg.ToolCallID, + }, + ) continue } diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go index 7281a0ead..81fc06ec6 100644 --- a/pkg/agent/pipeline_execute.go +++ b/pkg/agent/pipeline_execute.go @@ -131,6 +131,36 @@ toolLoop: toolName := tc.Name toolArgs := cloneStringAnyMap(tc.Arguments) + denyByTurnProfile := func() bool { + if turnProfileToolAllowed(ts.profile, toolName) { + return false + } + exec.allResponsesHandled = false + denyContent := fmt.Sprintf("Tool %q is not allowed by the active turn profile.", toolName) + al.emitEvent( + runtimeevents.KindAgentToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: toolName, + Reason: denyContent, + }, + ) + deniedMsg := providers.Message{ + Role: "tool", + Content: denyContent, + ToolCallID: tc.ID, + } + messages = append(messages, deniedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) + ts.recordPersistedMessage(deniedMsg) + } + return true + } + + if denyByTurnProfile() { + continue + } if al.hooks != nil { toolReq, decision := al.hooks.BeforeTool(turnCtx, &ToolCallHookRequest{ @@ -363,7 +393,9 @@ toolLoop: content := al.cfg.FilterSensitiveData(result.ForLLM) msg := subTurnResultPromptMessage(content) messages = append(messages, msg) - ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) + } } default: } @@ -441,6 +473,10 @@ toolLoop: } } + if denyByTurnProfile() { + continue + } + argsJSON, _ := json.Marshal(toolArgs) argsPreview := utils.Truncate(string(argsJSON), 200) logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", toolName, argsPreview), @@ -748,7 +784,9 @@ toolLoop: content := al.cfg.FilterSensitiveData(result.ForLLM) msg := subTurnResultPromptMessage(content) messages = append(messages, msg) - ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) + } } default: } @@ -802,7 +840,7 @@ toolLoop: }) } } - if ts.opts.EnableSummary { + if !ts.opts.NoHistory && ts.opts.EnableSummary { al.contextManager.Compact(turnCtx, &CompactRequest{ SessionKey: ts.sessionKey, Reason: ContextCompressReasonSummarize, diff --git a/pkg/agent/pipeline_finalize.go b/pkg/agent/pipeline_finalize.go index 1b674d0aa..d42f10917 100644 --- a/pkg/agent/pipeline_finalize.go +++ b/pkg/agent/pipeline_finalize.go @@ -65,7 +65,7 @@ func (p *Pipeline) Finalize( } } - if ts.opts.EnableSummary { + if !ts.opts.NoHistory && ts.opts.EnableSummary { al.contextManager.Compact( turnCtx, &CompactRequest{ diff --git a/pkg/agent/pipeline_llm.go b/pkg/agent/pipeline_llm.go index 6df2e4abd..0a31048a8 100644 --- a/pkg/agent/pipeline_llm.go +++ b/pkg/agent/pipeline_llm.go @@ -37,9 +37,10 @@ func (p *Pipeline) CallLLM( // PreLLM: graceful terminal handling exec.gracefulTerminal, _ = ts.gracefulInterruptRequested() exec.providerToolDefs = ts.agent.Tools.ToProviderDefs() + exec.providerToolDefs = filterToolsByTurnProfile(exec.providerToolDefs, ts.profile) // Native web search support - webSearchEnabled := al.cfg.Tools.IsToolEnabled("web") + webSearchEnabled := al.cfg.Tools.IsToolEnabled("web") && turnProfileToolAllowed(ts.profile, "web_search") exec.useNativeSearch = webSearchEnabled && al.cfg.Tools.Web.PreferNative && func() bool { if ns, ok := ts.agent.Provider.(providers.NativeSearchCapable); ok { @@ -47,7 +48,6 @@ func (p *Pipeline) CallLLM( } return false }() - if exec.useNativeSearch { filtered := make([]providers.ToolDefinition, 0, len(exec.providerToolDefs)) for _, td := range exec.providerToolDefs { @@ -94,8 +94,13 @@ func (p *Pipeline) CallLLM( prevModel := exec.llmModel exec.llmModel = llmReq.Model exec.callMessages = llmReq.Messages - exec.providerToolDefs = llmReq.Tools + exec.providerToolDefs = filterToolsByTurnProfile(llmReq.Tools, ts.profile) exec.llmOpts = llmReq.Options + nativeSearchAllowed := exec.useNativeSearch && + turnProfileToolAllowed(ts.profile, "web_search") + if !nativeSearchAllowed { + delete(exec.llmOpts, "native_search") + } if strings.TrimSpace(exec.llmModel) != "" && exec.llmModel != prevModel { p.applyBeforeLLMModelRewrite(ts, exec) applyTurnThinkingOptions(exec, ts.agent, exec.activeProvider, true) @@ -410,7 +415,7 @@ func (p *Pipeline) CallLLM( contextualSkills = ts.agent.ContextBuilder.ResolveActiveSkillsForContext(ts.activeSkills) } ts.recordSkillContextSnapshot(skillContextTriggerContextRetryRebuild, contextualSkills) - rebuildPromptReq := promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil) + rebuildPromptReq := promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil, p.Cfg) rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...) exec.messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq) exec.callMessages = exec.messages diff --git a/pkg/agent/pipeline_setup.go b/pkg/agent/pipeline_setup.go index 1fdd8f6c4..f764959e9 100644 --- a/pkg/agent/pipeline_setup.go +++ b/pkg/agent/pipeline_setup.go @@ -36,14 +36,14 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution contextualSkills = ts.agent.ContextBuilder.ResolveActiveSkillsForContext(ts.activeSkills) } ts.recordSkillContextSnapshot(skillContextTriggerInitialBuild, contextualSkills) - initialPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media) + initialPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media, cfg) initialPromptReq.ActiveSkills = append([]string(nil), contextualSkills...) messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt(initialPromptReq) messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize) if !ts.opts.NoHistory { - toolDefs := ts.agent.Tools.ToProviderDefs() + toolDefs := filterToolsByTurnProfile(ts.agent.Tools.ToProviderDefs(), ts.profile) if isOverContextBudget(ts.agent.ContextWindow, messages, toolDefs, ts.agent.MaxTokens) { logger.WarnCF("agent", "Proactive compression: context budget exceeded before LLM call", map[string]any{"session_key": ts.sessionKey}) @@ -66,7 +66,7 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution history = resp.History summary = resp.Summary } - rebuildPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media) + rebuildPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media, cfg) rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...) messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq) messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize) diff --git a/pkg/agent/prompt.go b/pkg/agent/prompt.go index 02c850360..1c66fe690 100644 --- a/pkg/agent/prompt.go +++ b/pkg/agent/prompt.go @@ -115,6 +115,13 @@ type PromptBuildRequest struct { ActiveSkills []string Overlays []PromptPart + + SuppressDefaultSystemPrompt bool + SuppressSkillContext bool + SuppressToolUseRule bool + AllowedSkills []string + AllowedTools []string + ToolUseFallback bool } type PromptContributor interface { diff --git a/pkg/agent/prompt_contributors.go b/pkg/agent/prompt_contributors.go index d6a2c09ec..3db5b8510 100644 --- a/pkg/agent/prompt_contributors.go +++ b/pkg/agent/prompt_contributors.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "strings" + + "github.com/sipeed/picoclaw/pkg/tools" ) type toolDiscoveryPromptContributor struct { @@ -23,9 +25,17 @@ func (c toolDiscoveryPromptContributor) PromptSource() PromptSourceDescriptor { func (c toolDiscoveryPromptContributor) ContributePrompt( _ context.Context, - _ PromptBuildRequest, + req PromptBuildRequest, ) ([]PromptPart, error) { - content := formatToolDiscoveryRule(c.useBM25, c.useRegex) + if req.SuppressToolUseRule { + return nil, nil + } + useBM25 := c.useBM25 && promptAllowsTool(req, tools.BM25SearchToolName) + useRegex := c.useRegex && promptAllowsTool(req, tools.RegexSearchToolName) + if !useBM25 && !useRegex { + return nil, nil + } + content := formatToolDiscoveryRule(useBM25, useRegex) if strings.TrimSpace(content) == "" { return nil, nil } @@ -62,12 +72,19 @@ func (c mcpServerPromptContributor) PromptSource() PromptSourceDescriptor { func (c mcpServerPromptContributor) ContributePrompt( _ context.Context, - _ PromptBuildRequest, + req PromptBuildRequest, ) ([]PromptPart, error) { + if req.SuppressToolUseRule { + return nil, nil + } serverName := strings.TrimSpace(c.serverName) if serverName == "" || c.toolCount <= 0 { return nil, nil } + if len(req.AllowedTools) > 0 && + !promptAllowsToolPrefix(req, "mcp_"+promptSourceComponent(serverName)+"_") { + return nil, nil + } availability := "available as native tools" if c.deferred { @@ -110,8 +127,14 @@ func (c agentDiscoveryPromptContributor) PromptSource() PromptSourceDescriptor { func (c agentDiscoveryPromptContributor) ContributePrompt( _ context.Context, - _ PromptBuildRequest, + req PromptBuildRequest, ) ([]PromptPart, error) { + if req.SuppressToolUseRule { + return nil, nil + } + if !promptAllowsTool(req, "spawn") { + return nil, nil + } if c.discover == nil { return nil, nil } @@ -178,3 +201,28 @@ func promptSourceComponent(value string) string { } return result } + +func promptAllowsTool(req PromptBuildRequest, name string) bool { + if len(req.AllowedTools) == 0 { + return true + } + allowed := cleanAllowedSet(req.AllowedTools) + _, ok := allowed[strings.ToLower(strings.TrimSpace(name))] + return ok +} + +func promptAllowsToolPrefix(req PromptBuildRequest, prefix string) bool { + if len(req.AllowedTools) == 0 { + return true + } + prefix = strings.ToLower(strings.TrimSpace(prefix)) + if prefix == "" { + return false + } + for _, name := range req.AllowedTools { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(name)), prefix) { + return true + } + } + return false +} diff --git a/pkg/agent/prompt_test.go b/pkg/agent/prompt_test.go index b76b0040d..605e5f4ab 100644 --- a/pkg/agent/prompt_test.go +++ b/pkg/agent/prompt_test.go @@ -197,6 +197,75 @@ func TestContextBuilder_CollectsToolDiscoveryContributor(t *testing.T) { } } +func TestContextBuilder_SuppressesToolDiscoveryContributorWhenToolsUnavailable(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()).WithToolDiscovery(true, false) + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "hello", + SuppressToolUseRule: true, + }) + system := messages[0] + if strings.Contains(system.Content, "tool_search_tool_bm25") { + t.Fatalf("system prompt includes tool discovery despite tools being unavailable: %q", system.Content) + } + for _, part := range system.SystemParts { + if part.PromptSource == string(PromptSourceToolDiscovery) { + t.Fatalf("system parts include tool discovery despite tools being unavailable: %#v", part) + } + } +} + +func TestContextBuilder_SuppressesToolReferencesWhenToolsUnavailable(t *testing.T) { + workspace := t.TempDir() + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + writeTurnProfileSkill( + t, + workspace, + "research", + "---\ndescription: research skill\n---\n# research\n\nResearch carefully.", + ) + cb := NewContextBuilder(workspace) + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "hello", + SuppressToolUseRule: true, + }) + system := messages[0] + if strings.Contains(system.Content, "When using tools") || + strings.Contains(system.Content, "read_file tool") || + strings.Contains(system.Content, "update "+workspace+"/memory/MEMORY.md") { + t.Fatalf("system prompt includes tool references despite tools being unavailable: %q", system.Content) + } + if !strings.Contains(system.Content, "research") { + t.Fatalf("system prompt should keep non-tool skill catalog context, got: %q", system.Content) + } +} + +func TestContextBuilder_CustomToolAllowListSuppressesReadFileSkillInstruction(t *testing.T) { + workspace := t.TempDir() + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + writeTurnProfileSkill( + t, + workspace, + "research", + "---\ndescription: research skill\n---\n# research\n\nResearch carefully.", + ) + cb := NewContextBuilder(workspace) + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "hello", + AllowedTools: []string{"web_search"}, + }) + system := messages[0] + if strings.Contains(system.Content, "read_file tool") { + t.Fatalf("system prompt includes read_file skill instruction without read_file permission: %q", system.Content) + } + if !strings.Contains(system.Content, "research") { + t.Fatalf("system prompt should keep skill catalog context, got: %q", system.Content) + } +} + func TestContextBuilder_CollectsMCPServerContributor(t *testing.T) { t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) cb := NewContextBuilder(t.TempDir()) @@ -232,6 +301,111 @@ func TestContextBuilder_CollectsMCPServerContributor(t *testing.T) { } } +func TestContextBuilder_SuppressesMCPServerContributorWhenToolsUnavailable(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()) + err := cb.RegisterPromptContributor(mcpServerPromptContributor{ + serverName: "GitHub Server", + toolCount: 3, + deferred: false, + }) + if err != nil { + t.Fatalf("RegisterPromptContributor() error = %v", err) + } + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "hello", + SuppressToolUseRule: true, + }) + system := messages[0] + if strings.Contains(system.Content, "MCP server `GitHub Server` is connected") || + strings.Contains(system.Content, "available as native tools") { + t.Fatalf("system prompt includes MCP tooling despite tools being unavailable: %q", system.Content) + } + for _, part := range system.SystemParts { + if part.PromptSource == "mcp:github_server" { + t.Fatalf("system parts include MCP tooling despite tools being unavailable: %#v", part) + } + } +} + +func TestContextBuilder_SuppressesAgentDiscoveryContributorWhenToolsUnavailable(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()).WithAgentDiscovery( + "main", + func(agentID string) []AgentDescriptor { + return []AgentDescriptor{{ + ID: "helper", + Name: "Helper", + Description: "Helps with tasks", + }} + }, + ) + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "hello", + SuppressToolUseRule: true, + }) + system := messages[0] + if strings.Contains(system.Content, "Agent Discovery") || + strings.Contains(system.Content, "calling spawn") { + t.Fatalf("system prompt includes agent discovery despite tools being unavailable: %q", system.Content) + } + for _, part := range system.SystemParts { + if part.PromptSource == string(PromptSourceAgentDiscovery) { + t.Fatalf("system parts include agent discovery despite tools being unavailable: %#v", part) + } + } +} + +func TestContextBuilder_CustomToolAllowListSuppressesUnallowedToolContributors(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()). + WithToolDiscovery(true, true). + WithAgentDiscovery( + "main", + func(agentID string) []AgentDescriptor { + return []AgentDescriptor{{ + ID: "helper", + Name: "Helper", + Description: "Helps with tasks", + }} + }, + ) + err := cb.RegisterPromptContributor(mcpServerPromptContributor{ + serverName: "GitHub Server", + toolCount: 3, + deferred: false, + }) + if err != nil { + t.Fatalf("RegisterPromptContributor() error = %v", err) + } + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "hello", + AllowedTools: []string{"echo_text"}, + }) + system := messages[0] + blockedSnippets := []string{ + "tool_search_tool_bm25", + "tool_search_tool_regex", + "MCP server `GitHub Server` is connected", + "Agent Discovery", + "calling spawn", + } + for _, snippet := range blockedSnippets { + if strings.Contains(system.Content, snippet) { + t.Fatalf("system prompt includes unallowed tool contributor %q: %q", snippet, system.Content) + } + } + for _, part := range system.SystemParts { + switch part.PromptSource { + case string(PromptSourceToolDiscovery), string(PromptSourceAgentDiscovery), "mcp:github_server": + t.Fatalf("system parts include unallowed tool contributor: %#v", part) + } + } +} + type testPromptContributor struct { desc PromptSourceDescriptor part PromptPart diff --git a/pkg/agent/prompt_turn.go b/pkg/agent/prompt_turn.go index 588a8f00f..053eb8460 100644 --- a/pkg/agent/prompt_turn.go +++ b/pkg/agent/prompt_turn.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -13,8 +14,9 @@ func promptBuildRequestForTurn( summary string, currentMessage string, media []string, + cfg *config.Config, ) PromptBuildRequest { - return PromptBuildRequest{ + req := PromptBuildRequest{ History: history, Summary: summary, CurrentMessage: currentMessage, @@ -26,6 +28,92 @@ func promptBuildRequestForTurn( ActiveSkills: activeSkillNames(ts.agent, ts.opts), Overlays: promptOverlaysForOptions(ts.opts), } + hasCallableTools := true + if ts.profile.Enabled { + hasCallableTools = turnProfileHasCallableTools(ts.profile, ts.agent.Tools.ToProviderDefs()) || + turnProfileNativeSearchCallable(cfg, ts.profile, ts.agent) + } + if turnProfileSystemPromptOff(ts.profile) { + req.SuppressDefaultSystemPrompt = true + req.SuppressSkillContext = true + req.ToolUseFallback = hasCallableTools + } + if ts.profile.Enabled && !hasCallableTools { + req.SuppressToolUseRule = true + } + if turnProfileSkillsOff(ts.profile) { + req.SuppressSkillContext = true + } + if turnProfileCustomSkills(ts.profile) { + req.AllowedSkills = append([]string(nil), ts.profile.AllowedSkills...) + } + if ts.profile.Enabled && ts.profile.ToolsMode == config.TurnProfileModeCustom { + req.AllowedTools = append([]string(nil), ts.profile.AllowedTools...) + } + return req +} + +func turnProfileNativeSearchCallable( + cfg *config.Config, + profile config.EffectiveTurnProfile, + agent *AgentInstance, +) bool { + if cfg == nil || agent == nil { + return false + } + if !cfg.Tools.IsToolEnabled("web") || !cfg.Tools.Web.PreferNative { + return false + } + if !turnProfileToolAllowed(profile, "web_search") { + return false + } + nativeProvider, ok := agent.Provider.(providers.NativeSearchCapable) + return ok && nativeProvider.SupportsNativeSearch() +} + +func promptBuildRequestForProcessOptions( + agent *AgentInstance, + opts processOptions, + history []providers.Message, + summary string, + currentMessage string, + media []string, +) PromptBuildRequest { + req := PromptBuildRequest{ + History: history, + Summary: summary, + CurrentMessage: currentMessage, + Media: append([]string(nil), media...), + Channel: opts.Channel, + ChatID: opts.ChatID, + SenderID: opts.SenderID, + SenderDisplayName: opts.SenderDisplayName, + ActiveSkills: activeSkillNames(agent, opts), + Overlays: promptOverlaysForOptions(opts), + } + profile := opts.TurnProfile + hasCallableTools := true + if profile.Enabled && agent != nil { + hasCallableTools = turnProfileHasCallableTools(profile, agent.Tools.ToProviderDefs()) + } + if turnProfileSystemPromptOff(profile) { + req.SuppressDefaultSystemPrompt = true + req.SuppressSkillContext = true + req.ToolUseFallback = hasCallableTools + } + if profile.Enabled && !hasCallableTools { + req.SuppressToolUseRule = true + } + if turnProfileSkillsOff(profile) { + req.SuppressSkillContext = true + } + if turnProfileCustomSkills(profile) { + req.AllowedSkills = append([]string(nil), profile.AllowedSkills...) + } + if profile.Enabled && profile.ToolsMode == config.TurnProfileModeCustom { + req.AllowedTools = append([]string(nil), profile.AllowedTools...) + } + return req } func promptOverlaysForOptions(opts processOptions) []PromptPart { diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index 86617d02f..fb9d94061 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -378,6 +378,7 @@ func spawnSubTurn( Dispatch: dispatch, SenderID: parentTS.opts.Dispatch.SenderID(), SenderDisplayName: parentTS.opts.SenderDisplayName, + TurnProfile: parentTS.profile, SystemPromptOverride: cfg.ActualSystemPrompt, InitialSteeringMessages: cfg.InitialMessages, DefaultResponse: "", @@ -386,6 +387,9 @@ func spawnSubTurn( NoHistory: true, // SubTurns don't use session history SkipInitialSteeringPoll: true, } + if !opts.TurnProfile.Enabled { + opts.TurnProfile = parentTS.opts.TurnProfile + } // Create event scope for the child turn scope := al.newTurnEventScope( diff --git a/pkg/agent/turn_coord.go b/pkg/agent/turn_coord.go index a8a6ff893..4e14c759b 100644 --- a/pkg/agent/turn_coord.go +++ b/pkg/agent/turn_coord.go @@ -373,6 +373,11 @@ func (al *AgentLoop) askSideQuestion( if opts != nil { normalizeProcessOptionsInPlace(opts) + resolved, err := resolveTurnProfileOptions(al.GetConfig(), *opts) + if err != nil { + return "", err + } + *opts = resolved } var media []string @@ -399,16 +404,31 @@ func (al *AgentLoop) askSideQuestion( } } - messages := agent.ContextBuilder.BuildMessages( - history, - summary, - question, - media, - channel, - chatID, - senderID, - senderDisplayName, - ) + var promptReq PromptBuildRequest + if opts == nil { + promptReq = PromptBuildRequest{ + History: history, + Summary: summary, + CurrentMessage: question, + Media: append([]string(nil), media...), + Channel: channel, + ChatID: chatID, + SenderID: senderID, + SenderDisplayName: senderDisplayName, + } + } else { + promptReq = promptBuildRequestForProcessOptions( + agent, + *opts, + history, + summary, + question, + media, + ) + } + promptReq.SuppressToolUseRule = true + promptReq.ToolUseFallback = false + messages := agent.ContextBuilder.BuildMessagesFromPrompt(promptReq) maxMediaSize := al.GetConfig().Agents.Defaults.GetMaxMediaSize() messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) @@ -487,6 +507,7 @@ func (al *AgentLoop) askSideQuestion( llmModel = llmReq.Model messages = llmReq.Messages llmOpts = llmReq.Options + delete(llmOpts, "native_search") } case HookActionAbortTurn: reason := decision.Reason diff --git a/pkg/agent/turn_coord_test.go b/pkg/agent/turn_coord_test.go index 4a63e639b..dc2715af7 100644 --- a/pkg/agent/turn_coord_test.go +++ b/pkg/agent/turn_coord_test.go @@ -73,6 +73,8 @@ func (p *sequenceProvider) GetDefaultModel() string { type nativeSearchCaptureProvider struct { lastOpts map[string]any + messages []providers.Message + tools []providers.ToolDefinition } func (p *nativeSearchCaptureProvider) Chat( @@ -82,6 +84,8 @@ func (p *nativeSearchCaptureProvider) Chat( model string, opts map[string]any, ) (*providers.LLMResponse, error) { + p.messages = append([]providers.Message(nil), messages...) + p.tools = append([]providers.ToolDefinition(nil), tools...) p.lastOpts = make(map[string]any, len(opts)) for k, v := range opts { p.lastOpts[k] = v diff --git a/pkg/agent/turn_profile_policy.go b/pkg/agent/turn_profile_policy.go new file mode 100644 index 000000000..140254e88 --- /dev/null +++ b/pkg/agent/turn_profile_policy.go @@ -0,0 +1,131 @@ +package agent + +import ( + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func resolveTurnProfileOptions(cfg *config.Config, opts processOptions) (processOptions, error) { + if cfg == nil { + return opts, nil + } + profile, ok, err := cfg.Agents.Defaults.ResolveTurnProfile() + if err != nil { + return opts, err + } + if !ok { + return opts, nil + } + opts.TurnProfile = profile + if profile.HistoryMode == config.TurnProfileModeOff { + opts.NoHistory = true + opts.EnableSummary = false + } + return opts, nil +} + +func turnProfileSystemPromptOff(profile config.EffectiveTurnProfile) bool { + return profile.Enabled && profile.SystemPromptMode == config.TurnProfileModeOff +} + +func turnProfileSkillsOff(profile config.EffectiveTurnProfile) bool { + return profile.Enabled && profile.SkillsMode == config.TurnProfileModeOff +} + +func turnProfileCustomSkills(profile config.EffectiveTurnProfile) bool { + return profile.Enabled && profile.SkillsMode == config.TurnProfileModeCustom +} + +func turnProfileHasCallableTools( + profile config.EffectiveTurnProfile, + defs []providers.ToolDefinition, +) bool { + if !profile.Enabled { + return true + } + return len(filterToolsByTurnProfile(defs, profile)) > 0 +} + +func turnProfileToolAllowed(profile config.EffectiveTurnProfile, name string) bool { + if !profile.Enabled { + return true + } + switch profile.ToolsMode { + case config.TurnProfileModeOff: + return false + case config.TurnProfileModeCustom: + allowed := cleanAllowedSet(profile.AllowedTools) + if len(allowed) == 0 { + return false + } + _, ok := allowed[strings.ToLower(strings.TrimSpace(name))] + return ok + default: + return true + } +} + +func toolUseSystemPromptRule() string { + return "**ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it." +} + +func filterNamesByTurnProfile(names []string, allowed []string) []string { + if len(names) == 0 { + return nil + } + allowedSet := cleanAllowedSet(allowed) + if len(allowedSet) == 0 { + return nil + } + out := make([]string, 0, len(names)) + for _, name := range names { + if _, ok := allowedSet[strings.ToLower(strings.TrimSpace(name))]; ok { + out = append(out, name) + } + } + return out +} + +func filterToolsByTurnProfile( + defs []providers.ToolDefinition, + profile config.EffectiveTurnProfile, +) []providers.ToolDefinition { + if !profile.Enabled { + return defs + } + switch profile.ToolsMode { + case config.TurnProfileModeOff: + return nil + case config.TurnProfileModeCustom: + allowed := cleanAllowedSet(profile.AllowedTools) + if len(allowed) == 0 { + return nil + } + filtered := make([]providers.ToolDefinition, 0, len(defs)) + for _, def := range defs { + if _, ok := allowed[strings.ToLower(strings.TrimSpace(def.Function.Name))]; ok { + filtered = append(filtered, def) + } + } + return filtered + default: + return defs + } +} + +func cleanAllowedSet(values []string) map[string]struct{} { + if len(values) == 0 { + return nil + } + out := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + continue + } + out[value] = struct{}{} + } + return out +} diff --git a/pkg/agent/turn_profile_test.go b/pkg/agent/turn_profile_test.go new file mode 100644 index 000000000..4783552c1 --- /dev/null +++ b/pkg/agent/turn_profile_test.go @@ -0,0 +1,1175 @@ +package agent + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" +) + +type turnProfileCaptureProvider struct { + messages []providers.Message + tools []providers.ToolDefinition +} + +func (p *turnProfileCaptureProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.messages = append([]providers.Message(nil), messages...) + p.tools = append([]providers.ToolDefinition(nil), tools...) + return &providers.LLMResponse{Content: "profile response"}, nil +} + +func (p *turnProfileCaptureProvider) GetDefaultModel() string { + return "test-model" +} + +type turnProfileSideQuestionCaptureProvider struct { + messages []providers.Message +} + +func (p *turnProfileSideQuestionCaptureProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.messages = append([]providers.Message(nil), messages...) + return &providers.LLMResponse{Content: "side answer"}, nil +} + +func (p *turnProfileSideQuestionCaptureProvider) GetDefaultModel() string { + return "test-model" +} + +func newTurnProfileAgentLoop( + t *testing.T, + cfg *config.Config, + provider *turnProfileCaptureProvider, +) *AgentLoop { + t.Helper() + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + if cfg.Agents.Defaults.Workspace == "" { + cfg.Agents.Defaults.Workspace = t.TempDir() + } + if cfg.Agents.Defaults.ModelName == "" { + cfg.Agents.Defaults.ModelName = "test-model" + } + if cfg.Agents.Defaults.MaxTokens == 0 { + cfg.Agents.Defaults.MaxTokens = 4096 + } + if cfg.Agents.Defaults.MaxToolIterations == 0 { + cfg.Agents.Defaults.MaxToolIterations = 10 + } + return NewAgentLoop(cfg, bus.NewMessageBus(), provider) +} + +func writeTurnProfileSkill(t *testing.T, workspace, name, body string) { + t.Helper() + dir := filepath.Join(workspace, "skills", name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir skill: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil { + t.Fatalf("write skill: %v", err) + } +} + +func TestTurnProfile_DisabledPreservesDefaultHistoryAndPrompt(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: false, + History: config.TurnProfileBlock{ + Mode: config.TurnProfileModeOff, + }, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + agent := al.GetRegistry().GetDefaultAgent() + sessionKey := "agent:default:test-default" + initialHistory := []providers.Message{ + {Role: "user", Content: "old user"}, + {Role: "assistant", Content: "old assistant"}, + } + agent.Sessions.SetHistory(sessionKey, initialHistory) + agent.Sessions.SetSummary(sessionKey, "old summary") + + got, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: sessionKey, + UserMessage: "new user", + DefaultResponse: defaultResponse, + EnableSummary: false, + }) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if got != "profile response" { + t.Fatalf("runAgentLoop() = %q, want profile response", got) + } + if len(provider.messages) != 4 { + t.Fatalf("provider messages len = %d, want system + history + user", len(provider.messages)) + } + if !reflect.DeepEqual(provider.messages[1:3], initialHistory) { + t.Fatalf("provider history = %#v, want %#v", provider.messages[1:3], initialHistory) + } + if !strings.Contains(provider.messages[0].Content, "CONTEXT_SUMMARY") { + t.Fatalf("system prompt missing summary in default mode:\n%s", provider.messages[0].Content) + } + history := agent.Sessions.GetHistory(sessionKey) + if len(history) != len(initialHistory)+2 { + t.Fatalf("history len = %d, want initial + user + assistant", len(history)) + } +} + +func TestTurnProfile_HistoryOffSuppressesHistoryAndPersistence(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + agent := al.GetRegistry().GetDefaultAgent() + sessionKey := "agent:default:test-history-off" + initialHistory := []providers.Message{ + {Role: "user", Content: "old user"}, + {Role: "assistant", Content: "old assistant"}, + } + agent.Sessions.SetHistory(sessionKey, initialHistory) + agent.Sessions.SetSummary(sessionKey, "old summary") + + _, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: sessionKey, + UserMessage: "new user", + DefaultResponse: defaultResponse, + EnableSummary: true, + }) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if len(provider.messages) != 2 { + t.Fatalf("provider messages len = %d, want system + current user", len(provider.messages)) + } + if provider.messages[1].Content != "new user" { + t.Fatalf("current message = %q, want new user", provider.messages[1].Content) + } + if strings.Contains(provider.messages[0].Content, "old summary") { + t.Fatalf("system prompt includes suppressed summary:\n%s", provider.messages[0].Content) + } + history := agent.Sessions.GetHistory(sessionKey) + if !reflect.DeepEqual(history, initialHistory) { + t.Fatalf("history = %#v, want unchanged %#v", history, initialHistory) + } +} + +func TestTurnProfile_ProcessMessageUsesEnabledTurnProfile(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + + _, err := al.processMessage(context.Background(), bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "pico", + ChatID: "pico:sess-1", + ChatType: "direct", + SenderID: "pico-user", + }, + Content: "hello from pico", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if len(provider.messages) != 2 { + t.Fatalf("provider messages len = %d, want system + current user", len(provider.messages)) + } + if provider.messages[1].Content != "hello from pico" { + t.Fatalf("current message = %q, want hello from pico", provider.messages[1].Content) + } +} + +func TestTurnProfile_BtwCommandUsesEnabledTurnProfile(t *testing.T) { + workspace := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + }, + }, + }, + ModelList: []*config.ModelConfig{{ + ModelName: "test-model", + Model: "openai/test-model", + }}, + } + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + sideProvider := &turnProfileSideQuestionCaptureProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), &turnProfileCaptureProvider{}) + al.providerFactory = func(mc *config.ModelConfig) (providers.LLMProvider, string, error) { + return sideProvider, "test-model", nil + } + + _, err := al.processMessage(context.Background(), bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "pico", + ChatID: "pico:btw", + ChatType: "direct", + SenderID: "pico-user", + }, + Content: "/btw explain privately", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if len(sideProvider.messages) < 2 { + t.Fatalf("side question messages len = %d, want system + user", len(sideProvider.messages)) + } + systemPrompt := sideProvider.messages[0].Content + blockedSnippets := []string{ + "ALWAYS use tools", + "When using tools", + "read_file tool", + "update " + workspace + "/memory/MEMORY.md", + } + for _, snippet := range blockedSnippets { + if strings.Contains(systemPrompt, snippet) { + t.Fatalf("side question system prompt includes %q despite tools.mode=off:\n%s", snippet, systemPrompt) + } + } +} + +func TestTurnProfile_BtwCommandDoesNotAddToolFallbackWhenSystemPromptOff(t *testing.T) { + workspace := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + SystemPrompt: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"echo_text"}, + }, + }, + }, + }, + ModelList: []*config.ModelConfig{{ + ModelName: "test-model", + Model: "openai/test-model", + }}, + } + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + sideProvider := &turnProfileSideQuestionCaptureProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), &turnProfileCaptureProvider{}) + al.RegisterTool(&echoTextTool{}) + al.providerFactory = func(mc *config.ModelConfig) (providers.LLMProvider, string, error) { + return sideProvider, "test-model", nil + } + + _, err := al.processMessage(context.Background(), bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "pico", + ChatID: "pico:btw-system-off", + ChatType: "direct", + SenderID: "pico-user", + }, + Content: "/btw explain privately", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + for _, msg := range sideProvider.messages { + if msg.Role == "system" && strings.Contains(msg.Content, toolUseSystemPromptRule()) { + t.Fatalf("side question system prompt includes tool fallback despite no tools:\n%s", msg.Content) + } + } +} + +func TestTurnProfile_BtwHookCannotReenableNativeSearchWhenToolsOff(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + }, + }, + }, + ModelList: []*config.ModelConfig{{ + ModelName: "test-model", + Model: "openai/test-model", + }}, + } + provider := &nativeSearchCaptureProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + al.providerFactory = func(mc *config.ModelConfig) (providers.LLMProvider, string, error) { + return provider, "test-model", nil + } + if err := al.MountHook(NamedHook("enable-native-search", turnProfileEnableNativeSearchHook{})); err != nil { + t.Fatalf("MountHook() error = %v", err) + } + + _, err := al.processMessage(context.Background(), bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "pico", + ChatID: "pico:btw-native-search", + ChatType: "direct", + SenderID: "pico-user", + }, + Content: "/btw search privately", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if provider.lastOpts["turn_profile_test_hook"] != true { + t.Fatalf("BeforeLLM hook did not run for /btw: %#v", provider.lastOpts) + } + if provider.lastOpts["native_search"] == true { + t.Fatalf("native_search option enabled by /btw hook despite tools.mode=off: %#v", provider.lastOpts) + } +} + +func TestTurnProfile_SubTurnInheritsParentToolProfile(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"echo_text"}, + }, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + al.RegisterTool(&echoTextTool{}) + al.RegisterTool(&echoTextRewrittenTool{}) + agent := al.GetRegistry().GetDefaultAgent() + profile, ok, err := cfg.Agents.Defaults.ResolveTurnProfile() + if err != nil { + t.Fatalf("ResolveTurnProfile() error = %v", err) + } + if !ok { + t.Fatal("ResolveTurnProfile() did not return enabled profile") + } + parentOpts := processOptions{ + Dispatch: DispatchRequest{ + SessionKey: "agent:default:test-parent", + UserMessage: "parent", + }, + TurnProfile: profile, + } + parentTS := newTurnState(agent, parentOpts, turnEventScope{ + turnID: "parent-turn-profile", + }) + + _, err = spawnSubTurn(context.Background(), al, parentTS, SubTurnConfig{ + Model: "test-model", + SystemPrompt: "child task", + Timeout: time.Second, + }) + if err != nil { + t.Fatalf("spawnSubTurn() error = %v", err) + } + if len(provider.tools) != 1 { + t.Fatalf("child provider tools len = %d, want 1: %#v", len(provider.tools), provider.tools) + } + if provider.tools[0].Function.Name != "echo_text" { + t.Fatalf("child provider tool = %q, want echo_text", provider.tools[0].Function.Name) + } +} + +func TestTurnProfile_SystemPromptOffUsesExternalPromptOnly(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + 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.TurnProfileModeOff}, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + agent := al.GetRegistry().GetDefaultAgent() + + _, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "agent:default:test-external-prompt", + UserMessage: "hello", + DefaultResponse: defaultResponse, + SystemPromptOverride: "External prompt only.", + }) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if len(provider.messages) != 2 { + t.Fatalf("messages len = %d, want system + user", len(provider.messages)) + } + if strings.TrimSpace(provider.messages[0].Content) != "External prompt only." { + t.Fatalf("system prompt = %q, want external only", provider.messages[0].Content) + } +} + +func TestTurnProfile_SystemPromptOffBlankTurnStillSendsMessage(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + 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.TurnProfileModeOff}, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + + _, err := al.runAgentLoop(context.Background(), al.GetRegistry().GetDefaultAgent(), processOptions{ + SessionKey: "agent:default:test-blank-system-off", + UserMessage: "", + DefaultResponse: defaultResponse, + }) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if len(provider.messages) != 1 { + t.Fatalf( + "provider messages len = %d, want one blank user message: %#v", + len(provider.messages), + provider.messages, + ) + } + if provider.messages[0].Role != "user" || provider.messages[0].Content != "" { + t.Fatalf("provider message = %#v, want blank user message", provider.messages[0]) + } +} + +func TestTurnProfile_SystemPromptOffAddsToolFallbackWhenToolsVisible(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + 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{"echo_text"}, + }, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + al.RegisterTool(&echoTextTool{}) + agent := al.GetRegistry().GetDefaultAgent() + + _, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "agent:default:test-tool-fallback", + UserMessage: "hello", + DefaultResponse: defaultResponse, + }) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if got := strings.TrimSpace(provider.messages[0].Content); got != toolUseSystemPromptRule() { + t.Fatalf("fallback prompt = %q, want existing tool rule %q", got, toolUseSystemPromptRule()) + } +} + +func TestTurnProfile_SkillsOffAndCustomControlCatalogAndActiveSkills(t *testing.T) { + workspace := t.TempDir() + writeTurnProfileSkill( + t, + workspace, + "shell", + "---\ndescription: shell skill\n---\n# shell\n\nUse shell carefully.", + ) + writeTurnProfileSkill( + t, + workspace, + "paint", + "---\ndescription: paint skill\n---\n# paint\n\nUse paint vividly.", + ) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{ + Mode: config.TurnProfileModeOff, + }, + Skills: config.TurnProfileBlock{ + Mode: config.TurnProfileModeOff, + }, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + agent := al.GetRegistry().GetDefaultAgent() + + _, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "agent:default:test-skills-off", + UserMessage: "hello", + DefaultResponse: defaultResponse, + ForcedSkills: []string{"shell"}, + }) + if err != nil { + t.Fatalf("runAgentLoop(no-skills) error = %v", err) + } + noSkillsPrompt := provider.messages[0].Content + if strings.Contains(noSkillsPrompt, "# Skills") || + strings.Contains(noSkillsPrompt, "# Active Skills") { + t.Fatalf("skills-off prompt includes skill context:\n%s", noSkillsPrompt) + } + + cfg.Agents.Defaults.TurnProfile.Skills = config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"shell"}, + } + provider = &turnProfileCaptureProvider{} + al = newTurnProfileAgentLoop(t, cfg, provider) + agent = al.GetRegistry().GetDefaultAgent() + + _, err = al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "agent:default:test-skills-custom", + UserMessage: "hello", + DefaultResponse: defaultResponse, + ForcedSkills: []string{"shell", "paint"}, + }) + if err != nil { + t.Fatalf("runAgentLoop(shell-only) error = %v", err) + } + customPrompt := provider.messages[0].Content + if !strings.Contains(customPrompt, "shell") || + !strings.Contains(customPrompt, "### Skill: shell") { + t.Fatalf("custom skills prompt missing allowed shell context:\n%s", customPrompt) + } + if strings.Contains(customPrompt, "paint") || + strings.Contains(customPrompt, "### Skill: paint") { + t.Fatalf("custom skills prompt includes disallowed paint context:\n%s", customPrompt) + } +} + +type turnProfileAddToolHook struct{} + +func (h turnProfileAddToolHook) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, error) { + next := req.Clone() + next.Tools = append(next.Tools, providers.ToolDefinition{ + Type: "function", + Function: providers.ToolFunctionDefinition{ + Name: "echo_text_rewritten", + Description: "hook-added tool", + Parameters: map[string]any{"type": "object"}, + }, + }) + return next, HookDecision{Action: HookActionModify}, nil +} + +type turnProfileEnableNativeSearchHook struct{} + +func (h turnProfileEnableNativeSearchHook) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, error) { + next := req.Clone() + if next.Options == nil { + next.Options = map[string]any{} + } + next.Options["turn_profile_test_hook"] = true + next.Options["native_search"] = true + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h turnProfileEnableNativeSearchHook) AfterLLM( + ctx context.Context, + resp *LLMHookResponse, +) (*LLMHookResponse, HookDecision, error) { + return resp, HookDecision{Action: HookActionContinue}, nil +} + +type turnProfileRespondToolHook struct{} + +func (h turnProfileRespondToolHook) BeforeTool( + ctx context.Context, + req *ToolCallHookRequest, +) (*ToolCallHookRequest, HookDecision, error) { + next := req.Clone() + next.HookResult = &tools.ToolResult{ForLLM: "hook bypassed profile"} + return next, HookDecision{Action: HookActionRespond}, nil +} + +func (h turnProfileRespondToolHook) AfterTool( + ctx context.Context, + result *ToolResultHookResponse, +) (*ToolResultHookResponse, HookDecision, error) { + return result, HookDecision{Action: HookActionContinue}, nil +} + +type turnProfileToolCallProvider struct { + calls int + messages []providers.Message +} + +func (p *turnProfileToolCallProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.calls++ + p.messages = append([]providers.Message(nil), messages...) + if p.calls == 1 { + return &providers.LLMResponse{ + Content: "calling disallowed", + ToolCalls: []providers.ToolCall{{ + ID: "call_1", + Name: "echo_text_rewritten", + Arguments: map[string]any{"text": "blocked"}, + }}, + FinishReason: "tool_calls", + }, nil + } + return &providers.LLMResponse{Content: "done"}, nil +} + +func (p *turnProfileToolCallProvider) GetDefaultModel() string { + return "test-model" +} + +func TestTurnProfile_ToolsCustomFiltersProviderToolsAndHookAdditions(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"echo_text"}, + }, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + al.RegisterTool(&echoTextTool{}) + al.RegisterTool(&echoTextRewrittenTool{}) + if err := al.MountHook(NamedHook("add-disallowed-tool", turnProfileAddToolHook{})); err != nil { + t.Fatalf("MountHook() error = %v", err) + } + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-tools-filter", + UserMessage: "hello", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if len(provider.tools) != 1 { + t.Fatalf("provider tools len = %d, want 1: %#v", len(provider.tools), provider.tools) + } + if provider.tools[0].Function.Name != "echo_text" { + t.Fatalf("provider tool = %q, want echo_text", provider.tools[0].Function.Name) + } +} + +func TestTurnProfile_ToolsOffDisablesProviderAndNativeSearchTools(t *testing.T) { + cfg := &config.Config{ + Tools: config.ToolsConfig{ + Web: config.WebToolsConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + PreferNative: true, + }, + }, + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + }, + }, + }, + } + cfg.Agents.Defaults.Workspace = t.TempDir() + cfg.Agents.Defaults.ModelName = "test-model" + cfg.Agents.Defaults.MaxTokens = 4096 + cfg.Agents.Defaults.MaxToolIterations = 10 + provider := &nativeSearchCaptureProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-tools-off", + UserMessage: "hello", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if provider.lastOpts["native_search"] == true { + t.Fatalf("native_search option enabled despite tools.mode=off: %#v", provider.lastOpts) + } +} + +func TestTurnProfile_ToolsOffSuppressesToolUsePromptRule(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + al.RegisterTool(&echoTextTool{}) + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-tools-off-prompt", + UserMessage: "hello", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if len(provider.tools) != 0 { + t.Fatalf("provider tools len = %d, want 0", len(provider.tools)) + } + if len(provider.messages) == 0 || provider.messages[0].Role != "system" { + t.Fatalf("first provider message = %#v, want system prompt", provider.messages) + } + if strings.Contains(provider.messages[0].Content, toolUseSystemPromptRule()) || + strings.Contains(provider.messages[0].Content, "**ALWAYS use tools**") { + t.Fatalf("tools-off system prompt still asks the model to use tools:\n%s", provider.messages[0].Content) + } +} + +func TestTurnProfile_ToolsCustomMissingToolSuppressesToolUsePromptRule(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"web_search"}, + }, + }, + }, + }, + } + provider := &turnProfileCaptureProvider{} + al := newTurnProfileAgentLoop(t, cfg, provider) + al.RegisterTool(&echoTextTool{}) + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-tools-custom-missing-prompt", + UserMessage: "hello", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if len(provider.tools) != 0 { + t.Fatalf("provider tools len = %d, want 0", len(provider.tools)) + } + if strings.Contains(provider.messages[0].Content, toolUseSystemPromptRule()) || + strings.Contains(provider.messages[0].Content, "**ALWAYS use tools**") { + t.Fatalf( + "custom profile with no resolved tools still asks the model to use tools:\n%s", + provider.messages[0].Content, + ) + } +} + +func TestTurnProfile_ToolsCustomAllowsNativeWebSearch(t *testing.T) { + cfg := &config.Config{ + Tools: config.ToolsConfig{ + Web: config.WebToolsConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + PreferNative: true, + }, + }, + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"web_search"}, + }, + }, + }, + }, + } + provider := &nativeSearchCaptureProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-native-web-allowed", + UserMessage: "search", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if got, _ := provider.lastOpts["native_search"].(bool); !got { + t.Fatalf("native_search = %#v, want true", provider.lastOpts["native_search"]) + } +} + +func TestTurnProfile_SystemPromptOffAddsToolFallbackForNativeWebSearch(t *testing.T) { + cfg := &config.Config{ + Tools: config.ToolsConfig{ + Web: config.WebToolsConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + PreferNative: true, + }, + }, + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + 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"}, + }, + }, + }, + }, + } + provider := &nativeSearchCaptureProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-native-web-fallback", + UserMessage: "search", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if got, _ := provider.lastOpts["native_search"].(bool); !got { + t.Fatalf("native_search = %#v, want true", provider.lastOpts["native_search"]) + } + if len(provider.messages) == 0 || provider.messages[0].Content != toolUseSystemPromptRule() { + t.Fatalf("native-search-only prompt = %#v, want tool fallback", provider.messages) + } +} + +func TestTurnProfile_BeforeLLMHookCannotReenableNativeSearchWhenToolsOff(t *testing.T) { + cfg := &config.Config{ + Tools: config.ToolsConfig{ + Web: config.WebToolsConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + PreferNative: true, + }, + }, + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + }, + }, + }, + } + provider := &nativeSearchCaptureProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + if err := al.MountHook(NamedHook("enable-native-search", turnProfileEnableNativeSearchHook{})); err != nil { + t.Fatalf("MountHook() error = %v", err) + } + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-native-web-hook-denied", + UserMessage: "search", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if provider.lastOpts["native_search"] == true { + t.Fatalf("native_search option enabled by hook despite tools.mode=off: %#v", provider.lastOpts) + } +} + +func TestTurnProfile_BeforeLLMHookCannotReenableNativeSearchWhenCustomToolsResolveEmpty( + t *testing.T, +) { + cfg := &config.Config{ + Tools: config.ToolsConfig{ + Web: config.WebToolsConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + PreferNative: true, + }, + }, + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"missing_tool"}, + }, + }, + }, + }, + } + provider := &nativeSearchCaptureProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + if err := al.MountHook(NamedHook("enable-native-search", turnProfileEnableNativeSearchHook{})); err != nil { + t.Fatalf("MountHook() error = %v", err) + } + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-native-web-hook-custom-empty", + UserMessage: "search", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if provider.lastOpts["native_search"] == true { + t.Fatalf( + "native_search option enabled by hook despite no resolved tools: %#v", + provider.lastOpts, + ) + } +} + +func TestTurnProfile_ToolExecutionRejectsDisallowedToolCalls(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"echo_text"}, + }, + }, + }, + }, + } + provider := &turnProfileToolCallProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + al.RegisterTool(&echoTextTool{}) + al.RegisterTool(&echoTextRewrittenTool{}) + + response, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-tool-exec-deny", + UserMessage: "run tool", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if response != "done" { + t.Fatalf("response = %q, want done", response) + } + if provider.calls != 2 { + t.Fatalf("provider calls = %d, want 2", provider.calls) + } + var foundDeniedResult bool + for _, msg := range provider.messages { + if msg.Role == "tool" && + strings.Contains(msg.Content, "not allowed by the active turn profile") { + foundDeniedResult = true + break + } + } + if !foundDeniedResult { + t.Fatalf("second provider call did not include denied tool result: %#v", provider.messages) + } +} + +func TestTurnProfile_BeforeToolRespondCannotBypassDisallowedTool(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + TurnProfile: config.TurnProfileConfig{ + Enabled: true, + History: config.TurnProfileBlock{Mode: config.TurnProfileModeOff}, + Tools: config.TurnProfileBlock{ + Mode: config.TurnProfileModeCustom, + Allow: []string{"echo_text"}, + }, + }, + }, + }, + } + provider := &turnProfileToolCallProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + al.RegisterTool(&echoTextTool{}) + al.RegisterTool(&echoTextRewrittenTool{}) + if err := al.MountHook(NamedHook("respond-tool", turnProfileRespondToolHook{})); err != nil { + t.Fatalf("MountHook() error = %v", err) + } + + _, err := al.runAgentLoop( + context.Background(), + al.GetRegistry().GetDefaultAgent(), + processOptions{ + SessionKey: "agent:default:test-tool-hook-respond-denied", + UserMessage: "run tool", + DefaultResponse: defaultResponse, + }, + ) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + var foundDeniedResult bool + for _, msg := range provider.messages { + if msg.Role != "tool" { + continue + } + if strings.Contains(msg.Content, "hook bypassed profile") { + t.Fatalf("hook respond result bypassed turn profile: %#v", provider.messages) + } + if strings.Contains(msg.Content, "not allowed by the active turn profile") { + foundDeniedResult = true + } + } + if !foundDeniedResult { + t.Fatalf("second provider call did not include denied tool result: %#v", provider.messages) + } +} diff --git a/pkg/agent/turn_state.go b/pkg/agent/turn_state.go index 2f40c2d30..4318dd28d 100644 --- a/pkg/agent/turn_state.go +++ b/pkg/agent/turn_state.go @@ -180,9 +180,10 @@ func newTurnExecution( type turnState struct { mu sync.RWMutex - agent *AgentInstance - opts processOptions - scope turnEventScope + agent *AgentInstance + opts processOptions + profile config.EffectiveTurnProfile + scope turnEventScope turnID string agentID string @@ -254,6 +255,7 @@ func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScop ts := &turnState{ agent: agent, opts: opts, + profile: opts.TurnProfile, scope: scope, turnID: scope.turnID, agentID: agent.ID, diff --git a/pkg/channels/pico/pico_test.go b/pkg/channels/pico/pico_test.go index 9e90fc539..a793d7ad7 100644 --- a/pkg/channels/pico/pico_test.go +++ b/pkg/channels/pico/pico_test.go @@ -36,6 +36,39 @@ func newTestPicoChannel(t *testing.T) *PicoChannel { return ch } +func TestHandleMessageSend_ForwardsMessageMetadata(t *testing.T) { + msgBus := bus.NewMessageBus() + bc := &config.Channel{Type: config.ChannelPico, Enabled: true} + cfg := &config.PicoSettings{} + cfg.SetToken("test-token") + ch, err := NewPicoChannel(bc, cfg, msgBus) + if err != nil { + t.Fatalf("NewPicoChannel: %v", err) + } + ch.ctx = context.Background() + + ch.handleMessageSend(&picoConn{id: "conn-1", sessionID: "sess-1"}, PicoMessage{ + Type: TypeMessageSend, + ID: "msg-1", + SessionID: "sess-1", + Payload: map[string]any{ + PayloadKeyContent: "hello", + }, + }) + + select { + case inbound := <-msgBus.InboundChan(): + if inbound.Content != "hello" { + t.Fatalf("content = %q, want hello", inbound.Content) + } + if got := inbound.Context.Raw["session_id"]; got != "sess-1" { + t.Fatalf("session_id raw = %q, want sess-1", got) + } + case <-time.After(time.Second): + t.Fatal("expected inbound pico message") + } +} + func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { ch := &PicoChannel{ progress: channels.NewToolFeedbackAnimator(nil), diff --git a/pkg/config/config.go b/pkg/config/config.go index 1fea47364..d9608d11e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 968fa9752..e34f23895 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -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 { diff --git a/pkg/config/turn_profile.go b/pkg/config/turn_profile.go new file mode 100644 index 000000000..967a697e5 --- /dev/null +++ b/pkg/config/turn_profile.go @@ -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 +} diff --git a/web/backend/api/config.go b/web/backend/api/config.go index 6bea67337..b37958040 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -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)) diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index 1531e7154..8a23b7a3f 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -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(), + ) } } diff --git a/web/frontend/src/components/chat/chat-composer.tsx b/web/frontend/src/components/chat/chat-composer.tsx index 569ed21e4..43bc8a463 100644 --- a/web/frontend/src/components/chat/chat-composer.tsx +++ b/web/frontend/src/components/chat/chat-composer.tsx @@ -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) => { - if (e.nativeEvent.isComposing) return + const handleKeyDown = (e: ReactKeyboardEvent) => { + 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({ onInputChange(e.target.value)} + onCompositionStart={() => { + composingRef.current = true + }} + onCompositionEnd={() => { + composingRef.current = false + }} onKeyDown={handleKeyDown} placeholder={placeholder} disabled={!canInput} diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 11ad1dd3c..3b4cac730 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -351,7 +351,7 @@ export function ChatPage() {
{messages.length === 0 && !isTyping && ( diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index 4db059cd3..b37877260 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -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 { + const result: Record = { + 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 = ( + 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} /> - + diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index cd6a68691..75baa397c 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -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: ( + 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 + }) => ( + + ) return ( @@ -215,6 +253,116 @@ export function AgentDefaultsSection({ } /> + + +
+ + onTurnProfileFieldChange("enabled", checked) + } + /> + +
+
+ + {renderModeSelect({ + value: form.turnProfile.historyMode, + onValueChange: (mode) => + onTurnProfileFieldChange( + "historyMode", + mode === "off" ? "off" : "default", + ), + allowCustom: false, + })} +

+ {t("pages.config.turn_profile_history_hint")} +

+
+ +
+ + {renderModeSelect({ + value: form.turnProfile.systemPromptMode, + onValueChange: (mode) => + onTurnProfileFieldChange( + "systemPromptMode", + mode === "off" ? "off" : "default", + ), + allowCustom: false, + })} +

+ {t("pages.config.turn_profile_system_prompt_hint")} +

+
+ +
+ + {renderModeSelect({ + value: form.turnProfile.skillsMode, + onValueChange: (mode) => + onTurnProfileFieldChange("skillsMode", mode), + allowCustom: true, + })} +

+ {t("pages.config.turn_profile_skills_hint")} +

+ {form.turnProfile.skillsMode === "custom" && ( +