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