diff --git a/config/config.example.json b/config/config.example.json index de12d84cce..0976a77458 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -18,6 +18,10 @@ "enabled": false, "max_args_length": 300, "separate_messages": false + }, + "dynamic_context": { + "time": "minute", + "position": "tail" } } }, diff --git a/docs/guides/configuration.fr.md b/docs/guides/configuration.fr.md index dcacc1874e..d205943f3e 100644 --- a/docs/guides/configuration.fr.md +++ b/docs/guides/configuration.fr.md @@ -110,6 +110,39 @@ Exemple de contexte propre avec outils web : } ``` +### Placement du contexte dynamique + +`agents.defaults.dynamic_context` contrôle le bloc de contexte propre à chaque requête — `## Runtime`, `## Current Session`, `## Current Sender` et `## Current Time`. + +| Clé | Valeurs | Défaut | Signification | +| --- | --- | --- | --- | +| `position` | `tail`, `system` | `tail` | Emplacement du bloc. `tail` le place après l'historique de conversation, porté par le message utilisateur courant dans une balise ``. `system` le garde dans le prompt système (la disposition précédente). | +| `time` | `minute`, `hour`, `off` | `minute` | Précision de `## Current Time`. `hour` arrondit l'horloge à l'heure inférieure ; `off` omet complètement l'heure. | + +**Pourquoi `tail` est la valeur par défaut.** La mise en cache de préfixe est positionnelle : modifier un token invalide tous les tokens suivants. Avec le bloc dans le prompt système, une horloge à la minute changeait quelque chose *avant* toute la conversation, si bien que l'historique entier était re-préchargé une fois par minute. Sur un hôte où un tour dépasse la minute, ce coût est payé à chaque tour : environ 2,2 ms par token d'historique, soit près de 13 s de re-préchargement pur par tour pour un historique de 6 000 tokens. + +Déplacer le bloc après l'historique rend le prompt système statique et l'historique complet identiques octet pour octet d'un tour à l'autre : les backends qui ne font que de la correspondance de préfixe d'octets (llama.cpp, Ollama et les autres endpoints compatibles OpenAI sans mécanisme de cache natif) touchent leur cache KV à chaque tour au lieu de le manquer chaque minute. Cela rend aussi le prompt statique identique pour tous les utilisateurs, toutes les sessions et toutes les exécutions cron, qui partagent alors un seul préfixe mis en cache au lieu de payer chacun un préchargement à froid. + +Anthropic (`cache_control`) et OpenAI (`prompt_cache_key`) ne sont pas concernés : leurs mécanismes natifs s'appliquent toujours. + +Ne mettez `position` à `system` que si vous avez besoin de la disposition précédente. Notez que le bloc n'est jamais émis comme message système final : les adaptateurs de providers remontent les messages système en tête, et certains ne gardent que le dernier, ce qui écraserait le prompt statique. + +```json +{ + "agents": { + "defaults": { + "dynamic_context": { + "time": "minute", + "position": "tail" + } + } + } +} +``` + +- `position` : `tail` (par défaut) ou `system` pour rétablir l'ancienne disposition. +- `time` : `minute` (par défaut), `hour` pour élargir encore la fenêtre de réutilisation, ou `off` pour supprimer l'horloge. + ### Sources de Compétences Par défaut, les compétences sont chargées depuis : diff --git a/docs/guides/configuration.ja.md b/docs/guides/configuration.ja.md index 7dd6764479..066e41994a 100644 --- a/docs/guides/configuration.ja.md +++ b/docs/guides/configuration.ja.md @@ -111,6 +111,39 @@ Web ツールだけを残すクリーンなコンテキスト例: } ``` +### 動的コンテキストの配置 + +`agents.defaults.dynamic_context` は、リクエストごとに変化するコンテキストブロック(`## Runtime`、`## Current Session`、`## Current Sender`、`## Current Time`)を制御します。 + +| キー | 値 | 既定値 | 意味 | +| --- | --- | --- | --- | +| `position` | `tail`、`system` | `tail` | ブロックの配置場所。`tail` は会話履歴の後ろに置き、`` タグで囲んで現在のユーザーメッセージに載せます。`system` は system prompt の中に残します(従来のレイアウト)。 | +| `time` | `minute`、`hour`、`off` | `minute` | `## Current Time` の精度。`hour` は時単位に切り捨てた時刻を出力し、`off` は時刻を出力しません。 | + +**`tail` が既定である理由。** プレフィックスキャッシュは位置に依存します。あるトークンが変わると、それ以降のトークンはすべて無効になります。このブロックが system prompt の中にあると、分単位の時刻が会話全体より前で毎分変化するため、履歴全体が毎分再 prefill されていました。1 ターンが 1 分を超えるホストでは、このコストを毎ターン支払うことになります。履歴トークンあたり約 2.2 ms、6,000 トークンの履歴なら 1 ターンあたり約 13 秒が純粋な再 prefill に費やされる計算です。 + +ブロックを履歴の後ろに移すと、静的な system prompt と履歴全体がターン間でバイト単位で同一になり、バイト列のプレフィックス一致だけを行うバックエンド(llama.cpp、Ollama、その他ネイティブなキャッシュ機構を持たない OpenAI 互換エンドポイント)が、毎分ミスする代わりに毎ターンキャッシュにヒットします。さらに静的プロンプトがすべてのユーザー・セッション・cron 実行で同一になるため、それぞれがコールドな prefill を払う代わりに 1 つのキャッシュ済みプレフィックスを共有できます。 + +Anthropic(`cache_control`)と OpenAI(`prompt_cache_key`)は影響を受けません。それぞれのネイティブ機構がそのまま適用されます。 + +従来のプロンプトレイアウトが必要な場合にのみ `position` を `system` にしてください。なお、このブロックは末尾の system メッセージとしては送信されません。provider アダプタは system メッセージを先頭へ移動させ、一部は最後の 1 件だけを残すため、静的プロンプトが破棄されてしまいます。 + +```json +{ + "agents": { + "defaults": { + "dynamic_context": { + "time": "minute", + "position": "tail" + } + } + } +} +``` + +- `position`: `tail`(既定)または `system`(以前のレイアウトに戻す)。 +- `time`: `minute`(既定)、`hour`(再利用ウィンドウをさらに広げる)、`off`(時刻を出力しない)。 + ### スキルソース デフォルトでは、スキルは以下の順序で読み込まれます: diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 8ac866880f..a24804b5a2 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -143,6 +143,38 @@ Example clean web policy: } ``` +### Dynamic Context Placement + +`agents.defaults.dynamic_context` controls the per-request context block — `## Runtime`, `## Current Session`, `## Current Sender` and `## Current Time`. + +| Key | Values | Default | Meaning | +| --- | --- | --- | --- | +| `position` | `tail`, `system` | `tail` | Where the block is placed. `tail` puts it after the conversation history, carried on the current user message inside a `` tag. `system` keeps it inside the system prompt (the previous layout). | +| `time` | `minute`, `hour`, `off` | `minute` | Precision of `## Current Time`. `hour` reports the clock rounded down to the hour; `off` omits the time entirely. | + +**Why `tail` is the default.** Prefix caching is positional: changing any token invalidates every token after it. With the block in the system prompt, a minute-precision clock changes something *before* the entire conversation, so the whole history is re-prefilled once per minute. On a host where a turn takes longer than a minute, that cost is paid every single turn — roughly 2.2 ms per history token, so a 6,000-token history burns about 13 s of pure re-prefill per turn. + +Moving the block after the history makes the static system prompt and the full history byte-identical from turn to turn, so a backend that only does byte-prefix matching keeps its KV cache. It also makes the static prompt identical across *all* users, sessions and cron runs, letting them share one cached prefix instead of each paying a cold prefill. + +This matters most for local backends — llama.cpp, Ollama, and any other OpenAI-compatible endpoint without a native caching mechanism. Anthropic (per-block `cache_control`) and OpenAI (`prompt_cache_key`) have their own mechanisms, which are unaffected either way. + +Set `position` to `system` only if you need the previous prompt layout. Note the block is never emitted as a trailing *system* message: provider adapters hoist system messages to the front, and some keep only the last one, which would discard the static prompt. + +```json +{ + "agents": { + "defaults": { + "dynamic_context": { + "time": "minute", + "position": "tail" + } + } + } +} +``` + +Operators who don't need wall-clock awareness at all can widen the reuse window further with `"time": "hour"` or drop it with `"time": "off"`. + ### Web launcher dashboard **picoclaw-launcher** serves a browser UI that requires password sign-in first. On first run, open `/launcher-setup` to create the dashboard password. Later manual sign-ins use `/launcher-login`. diff --git a/docs/guides/configuration.pt-br.md b/docs/guides/configuration.pt-br.md index bb5d14131f..b7782b172b 100644 --- a/docs/guides/configuration.pt-br.md +++ b/docs/guides/configuration.pt-br.md @@ -111,6 +111,39 @@ Exemplo de contexto limpo com ferramentas web: } ``` +### Posicionamento do contexto dinâmico + +`agents.defaults.dynamic_context` controla o bloco de contexto de cada requisição — `## Runtime`, `## Current Session`, `## Current Sender` e `## Current Time`. + +| Chave | Valores | Padrão | Significado | +| --- | --- | --- | --- | +| `position` | `tail`, `system` | `tail` | Onde o bloco fica. `tail` o coloca depois do histórico da conversa, carregado na mensagem de usuário atual dentro de uma tag ``. `system` o mantém dentro do prompt de sistema (o layout anterior). | +| `time` | `minute`, `hour`, `off` | `minute` | Precisão de `## Current Time`. `hour` arredonda o relógio para baixo até a hora; `off` omite o horário por completo. | + +**Por que `tail` é o padrão.** O cache de prefixo é posicional: alterar qualquer token invalida todos os tokens seguintes. Com o bloco no prompt de sistema, um relógio com precisão de minuto mudava algo *antes* de toda a conversa, então o histórico inteiro era re-preenchido uma vez por minuto. Em um host onde um turno passa de um minuto, esse custo é pago em todo turno: cerca de 2,2 ms por token de histórico, ou seja, aproximadamente 13 s de re-prefill puro por turno em um histórico de 6.000 tokens. + +Mover o bloco para depois do histórico deixa o prompt de sistema estático e todo o histórico idênticos byte a byte entre turnos, de modo que backends que só fazem correspondência de prefixo de bytes (llama.cpp, Ollama e outros endpoints compatíveis com OpenAI sem mecanismo de cache nativo) acertam o cache KV a cada turno em vez de errar a cada minuto. Isso também torna o prompt estático idêntico para todos os usuários, sessões e execuções de cron, que passam a compartilhar um único prefixo em cache em vez de cada um pagar um prefill frio. + +Anthropic (`cache_control`) e OpenAI (`prompt_cache_key`) não são afetados — seus mecanismos nativos continuam valendo. + +Defina `position` como `system` apenas se precisar do layout de prompt anterior. Note que o bloco nunca é emitido como uma mensagem de sistema final: os adaptadores de provider movem mensagens de sistema para o início, e alguns mantêm apenas a última, o que descartaria o prompt estático. + +```json +{ + "agents": { + "defaults": { + "dynamic_context": { + "time": "minute", + "position": "tail" + } + } + } +} +``` + +- `position`: `tail` (padrão) ou `system` para restaurar o layout antigo. +- `time`: `minute` (padrão), `hour` para ampliar ainda mais a janela de reuso, ou `off` para remover o relógio. + ### Fontes de Skills Por padrão, as skills são carregadas de: diff --git a/docs/guides/configuration.vi.md b/docs/guides/configuration.vi.md index 516ebe9fea..ec4f42c7be 100644 --- a/docs/guides/configuration.vi.md +++ b/docs/guides/configuration.vi.md @@ -111,6 +111,39 @@ Ví dụ ngữ cảnh sạch chỉ giữ tool web: } ``` +### Vị trí ngữ cảnh động + +`agents.defaults.dynamic_context` kiểm soát block ngữ cảnh thay đổi theo từng request — `## Runtime`, `## Current Session`, `## Current Sender` và `## Current Time`. + +| Key | Giá trị | Mặc định | Ý nghĩa | +| --- | --- | --- | --- | +| `position` | `tail`, `system` | `tail` | Vị trí đặt block. `tail` đặt sau lịch sử hội thoại, gắn vào tin nhắn user hiện tại bên trong thẻ ``. `system` giữ nó trong system prompt (bố cục trước đây). | +| `time` | `minute`, `hour`, `off` | `minute` | Độ chính xác của `## Current Time`. `hour` làm tròn xuống theo giờ; `off` bỏ hẳn phần thời gian. | + +**Vì sao `tail` là mặc định.** Prefix cache phụ thuộc vị trí: thay đổi một token sẽ vô hiệu hóa toàn bộ token phía sau. Khi block nằm trong system prompt, đồng hồ chính xác đến phút thay đổi ở *trước* toàn bộ hội thoại, nên cả lịch sử phải prefill lại mỗi phút một lần. Trên máy mà mỗi turn kéo dài hơn một phút, chi phí này phải trả ở mọi turn: khoảng 2,2 ms cho mỗi token lịch sử, tức khoảng 13 giây prefill thuần mỗi turn với lịch sử 6.000 token. + +Chuyển block ra sau lịch sử giúp system prompt tĩnh và toàn bộ lịch sử giống nhau từng byte giữa các turn, nhờ đó các backend chỉ so khớp prefix theo byte (llama.cpp, Ollama và các endpoint tương thích OpenAI khác không có cơ chế cache riêng) trúng KV cache ở mọi turn thay vì trượt mỗi phút. Điều này cũng làm prompt tĩnh giống hệt nhau giữa tất cả người dùng, tất cả session và mọi lần chạy cron, để chúng dùng chung một prefix đã cache thay vì mỗi bên phải chịu một lần prefill nguội. + +Anthropic (`cache_control`) và OpenAI (`prompt_cache_key`) không bị ảnh hưởng — cơ chế riêng của chúng vẫn hoạt động. + +Chỉ đặt `position` thành `system` nếu bạn cần bố cục prompt trước đây. Lưu ý block không bao giờ được gửi dưới dạng system message ở cuối: các adapter provider sẽ đẩy system message lên đầu, và một số chỉ giữ lại cái cuối cùng, khiến prompt tĩnh bị mất. + +```json +{ + "agents": { + "defaults": { + "dynamic_context": { + "time": "minute", + "position": "tail" + } + } + } +} +``` + +- `position`: `tail` (mặc định) hoặc `system` để khôi phục bố cục cũ. +- `time`: `minute` (mặc định), `hour` để mở rộng thêm cửa sổ tái sử dụng, hoặc `off` để bỏ hẳn đồng hồ. + ### Nguồn Skill Mặc định, skill được tải từ: diff --git a/docs/guides/configuration.zh.md b/docs/guides/configuration.zh.md index 1d04a0d481..ab9f9ddbbd 100644 --- a/docs/guides/configuration.zh.md +++ b/docs/guides/configuration.zh.md @@ -141,6 +141,39 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work } ``` +### 动态上下文位置 + +`agents.defaults.dynamic_context` 用来控制每次请求都会变化的上下文块 —— `## Runtime`、`## Current Session`、`## Current Sender` 和 `## Current Time`。 + +| 键 | 取值 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `position` | `tail`、`system` | `tail` | 该块的位置。`tail` 把它放在对话历史之后,包在 `` 标签内随当前用户消息一起发送;`system` 保留在 system prompt 内部(此前的布局)。 | +| `time` | `minute`、`hour`、`off` | `minute` | `## Current Time` 的精度。`hour` 只报到小时(分钟向下取整),`off` 完全不输出时间。 | + +**为什么默认是 `tail`。** 前缀缓存是按位置生效的:改动任意一个 token,其后的全部 token 都会失效。该块原本位于 system prompt 中,分钟级时间戳每分钟变一次,而它排在整段对话之前,于是整个历史每分钟就要重新 prefill 一遍。在单轮耗时超过一分钟的机器上,这个代价每轮都要付:约每个历史 token 2.2 ms,6000 token 的历史相当于每轮约 13 秒的纯 prefill。 + +把该块移到历史之后,静态 system prompt 和整段历史在相邻回合之间即可保持逐字节一致,只做字节前缀匹配的后端(llama.cpp、Ollama,以及其他没有原生缓存机制的 OpenAI 兼容端点)每轮都能命中 KV 缓存,而不是每分钟失效一次。同时静态提示词在所有用户、所有会话和所有定时任务之间也完全一致,可以共用同一段缓存前缀,而不必各自承担一次冷 prefill。 + +Anthropic(`cache_control`)和 OpenAI(`prompt_cache_key`)不受影响,它们的原生机制照常生效。 + +只有在需要恢复此前的提示词布局时,才把 `position` 设为 `system`。注意该块不会作为末尾的 system 消息发送:各 provider 适配器会把 system 消息前移,其中一部分只保留最后一条,那样会把静态提示词整个丢掉。 + +```json +{ + "agents": { + "defaults": { + "dynamic_context": { + "time": "minute", + "position": "tail" + } + } + } +} +``` + +- `position`:`tail`(默认)或 `system`(恢复旧布局)。 +- `time`:`minute`(默认)、`hour`(进一步扩大复用窗口)或 `off`(完全不带时间)。 + ### Web 启动器控制台 用 **picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`。 diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index fb505d1e42..2cd488a055 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -520,14 +520,14 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) { t.Fatal("provider did not receive any messages") } - systemPrompt := provider.lastMessages[0].Content + lastMessage := provider.lastMessages[len(provider.lastMessages)-1] + dynamicContext := runtimeContextBlock(lastMessage.Content) wantSender := "## Current Sender\nCurrent sender: Alice (ID: discord:123)" - if !strings.Contains(systemPrompt, wantSender) { - t.Fatalf("system prompt missing sender context %q:\n%s", wantSender, systemPrompt) + if !strings.Contains(dynamicContext, wantSender) { + t.Fatalf("runtime context missing sender %q:\n%s", wantSender, dynamicContext) } - lastMessage := provider.lastMessages[len(provider.lastMessages)-1] - if lastMessage.Role != "user" || lastMessage.Content != "hello" { + if lastMessage.Role != "user" || stripRuntimeContext(lastMessage.Content) != "hello" { t.Fatalf("last provider message = %+v, want unchanged user message", lastMessage) } } @@ -1079,7 +1079,7 @@ func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) { } lastMessage := provider.lastMessages[len(provider.lastMessages)-1] - if lastMessage.Role != "user" || lastMessage.Content != "explain how to list files" { + if lastMessage.Role != "user" || stripRuntimeContext(lastMessage.Content) != "explain how to list files" { t.Fatalf("last provider message = %+v, want rewritten user message", lastMessage) } } @@ -1150,7 +1150,7 @@ func TestProcessMessage_BtwCommandRunsWithoutPersistingHistory(t *testing.T) { } lastMessage := provider.lastMessages[len(provider.lastMessages)-1] - if lastMessage.Role != "user" || lastMessage.Content != "explain side effects" { + if lastMessage.Role != "user" || stripRuntimeContext(lastMessage.Content) != "explain side effects" { t.Fatalf("last provider message = %+v, want stripped /btw question", lastMessage) } @@ -1198,16 +1198,16 @@ func TestProcessMessage_BtwCommandIncludesRequestContextAndMedia(t *testing.T) { t.Fatal("provider did not receive any messages") } - systemPrompt := provider.lastMessages[0].Content - if !strings.Contains(systemPrompt, "## Current Session\nChannel: discord\nChat ID: group-1") { - t.Fatalf("system prompt missing current session context:\n%s", systemPrompt) + lastMessage := provider.lastMessages[len(provider.lastMessages)-1] + dynamicContext := runtimeContextBlock(lastMessage.Content) + if !strings.Contains(dynamicContext, "## Current Session\nChannel: discord\nChat ID: group-1") { + t.Fatalf("runtime context missing current session:\n%s", dynamicContext) } - if !strings.Contains(systemPrompt, "## Current Sender\nCurrent sender: Alice (ID: discord:123)") { - t.Fatalf("system prompt missing current sender context:\n%s", systemPrompt) + if !strings.Contains(dynamicContext, "## Current Sender\nCurrent sender: Alice (ID: discord:123)") { + t.Fatalf("runtime context missing current sender:\n%s", dynamicContext) } - lastMessage := provider.lastMessages[len(provider.lastMessages)-1] - if lastMessage.Role != "user" || lastMessage.Content != "describe this image" { + if lastMessage.Role != "user" || stripRuntimeContext(lastMessage.Content) != "describe this image" { t.Fatalf("last provider message = %+v, want stripped /btw question", lastMessage) } if !reflect.DeepEqual(lastMessage.Media, []string{"media://image-1"}) { @@ -1273,7 +1273,7 @@ func TestProcessMessage_BtwCommandUsesIsolatedProvider(t *testing.T) { // Verify the question was stripped of /btw prefix lastMessage := provider.lastMessages[len(provider.lastMessages)-1] - if lastMessage.Role != "user" || lastMessage.Content != "explain isolation" { + if lastMessage.Role != "user" || stripRuntimeContext(lastMessage.Content) != "explain isolation" { t.Fatalf("last provider message = %+v, want stripped /btw question", lastMessage) } @@ -1506,7 +1506,7 @@ func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) { t.Fatalf("system prompt missing pending skill content:\n%s", systemPrompt) } lastMessage := provider.lastMessages[len(provider.lastMessages)-1] - if lastMessage.Role != "user" || lastMessage.Content != "explain how to list files" { + if lastMessage.Role != "user" || stripRuntimeContext(lastMessage.Content) != "explain how to list files" { t.Fatalf("last provider message = %+v, want unchanged follow-up user message", lastMessage) } } diff --git a/pkg/agent/agent_utils.go b/pkg/agent/agent_utils.go index 85228b5869..e606338838 100644 --- a/pkg/agent/agent_utils.go +++ b/pkg/agent/agent_utils.go @@ -146,7 +146,10 @@ func latestUserContent(messages []providers.Message) string { if msg.Role != "user" { continue } - if content := strings.TrimSpace(msg.Content); content != "" { + // Strip the tail-placed runtime context: this value is shown to the + // user in tool feedback, and the block is machine preamble they never + // typed. + if content := strings.TrimSpace(stripRuntimeContext(msg.Content)); content != "" { return content } } diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 74c278f39c..381a6d1e5f 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -29,6 +29,11 @@ type ContextBuilder struct { agentDiscovery func(agentID string) []AgentDescriptor promptRegistry *PromptRegistry + // dynamicContext controls the precision and placement of the per-request + // dynamic block (time / runtime / session / sender). The zero value resolves + // to config.DefaultDynamicContext() (minute precision, tail placement). + dynamicContext config.EffectiveDynamicContext + // Cache for system prompt to avoid rebuilding on every call. // This fixes issue #607: repeated reprocessing of the entire context. // The cache auto-invalidates when workspace source files change (mtime check). @@ -71,6 +76,24 @@ func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder { return cb } +// WithDynamicContext sets the resolved agents.defaults.dynamic_context options. +func (cb *ContextBuilder) WithDynamicContext( + opts config.EffectiveDynamicContext, +) *ContextBuilder { + cb.dynamicContext = opts + return cb +} + +// dynamicContextOptions returns the effective dynamic context settings, +// filling in defaults for a zero-valued (never configured) builder so that +// directly constructed ContextBuilders behave like configured ones. +func (cb *ContextBuilder) dynamicContextOptions() config.EffectiveDynamicContext { + return config.EffectiveDynamicContext{ + Time: cb.dynamicContext.Time.Effective(), + Position: cb.dynamicContext.Position.Effective(), + } +} + func (cb *ContextBuilder) WithAgentDiscovery( agentID string, discover func(agentID string) []AgentDescriptor, @@ -484,6 +507,9 @@ func (cb *ContextBuilder) EstimateSystemTokens(summary string, activeSkills []st // Dynamic context is small and varies per request; use a representative estimate. // Actual buildDynamicContext produces ~200-400 chars of time/runtime/session info. + // Counted regardless of placement: with position "tail" the block rides on the + // current user message instead of the system message, but the tokens are still + // sent and still consume the context window. const dynamicContextChars = 300 totalChars := utf8.RuneCountInString(staticPrompt) + dynamicContextChars @@ -781,6 +807,12 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { // - Anthropic: per-block cache_control (ephemeral) on the static SystemParts block // - OpenAI / Codex: prompt_cache_key for prefix-based caching // +// Neither of those mechanisms exists for a local llama.cpp/Ollama backend, where +// the only caching is byte-prefix matching. Because prefix caching is positional, +// keeping this block ahead of the history re-prefills the entire conversation once +// per minute. agents.defaults.dynamic_context.position therefore defaults to +// "tail", which moves the block after the history — see dynamic_context.go. +// // See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching // See: https://platform.openai.com/docs/guides/prompt-caching func formatCurrentSenderLine(senderID, senderDisplayName string) string { @@ -799,14 +831,38 @@ func formatCurrentSenderLine(senderID, senderDisplayName string) string { } } +// formatCurrentTime renders the clock at the configured precision. Returns "" +// when the time section is disabled. +func formatCurrentTime(now time.Time, precision config.DynamicContextTime) string { + switch precision.Effective() { + case config.DynamicContextTimeOff: + return "" + case config.DynamicContextTimeHour: + // Literal "00" minutes: the value is stable for a whole hour, so the + // rendered block (and every token after it) stays byte-identical. + return now.Format("2006-01-02 15:00 (Monday)") + default: + return now.Format("2006-01-02 15:04 (Monday)") + } +} + +// buildDynamicContext emits the per-request block ordered strictly by +// volatility — least volatile first — so that when the block is kept inside the +// system prompt (position "system") a clock tick only invalidates the tail of +// the block rather than the session and sender lines above it. +// +// ## Runtime static for the life of the process +// ## Current Session per session +// ## Current Sender per sender +// ## Current Time per minute (or per hour, or omitted) func (cb *ContextBuilder) buildDynamicContext( channel, chatID, senderID, senderDisplayName string, ) string { - now := time.Now().Format("2006-01-02 15:04 (Monday)") + opts := cb.dynamicContextOptions() rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) var sb strings.Builder - fmt.Fprintf(&sb, "## Current Time\n%s\n\n## Runtime\n%s", now, rt) + fmt.Fprintf(&sb, "## Runtime\n%s", rt) if channel != "" && chatID != "" { fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID) @@ -814,10 +870,51 @@ func (cb *ContextBuilder) buildDynamicContext( if senderLine := formatCurrentSenderLine(senderID, senderDisplayName); senderLine != "" { fmt.Fprintf(&sb, "\n\n## Current Sender\n%s", senderLine) } + if now := formatCurrentTime(time.Now(), opts.Time); now != "" { + fmt.Fprintf(&sb, "\n\n## Current Time\n%s", now) + } return sb.String() } +// runtimeContextTag wraps the dynamic block when it is placed at the tail, so +// the model does not read the runtime metadata as part of the user's own text. +const ( + runtimeContextOpenTag = "" + runtimeContextCloseTag = "" +) + +// stripRuntimeContext removes a tail-placed block from a +// message, returning the user's own text. Anything that surfaces message +// content to a human — tool feedback explanations, previews, logs — must go +// through this, because with position "tail" the wire message carries the +// runtime block as a preamble that the user never typed. +func stripRuntimeContext(content string) string { + start := strings.Index(content, runtimeContextOpenTag) + if start < 0 { + return content + } + end := strings.Index(content, runtimeContextCloseTag) + if end < start { + return content + } + end += len(runtimeContextCloseTag) + return strings.TrimSpace(content[:start] + content[end:]) +} + +// wrapTailDynamicContext renders the dynamic block as a tagged preamble to the +// current user message. +func wrapTailDynamicContext(dynamicCtx, userMessage string) string { + if strings.TrimSpace(dynamicCtx) == "" { + return userMessage + } + wrapped := runtimeContextOpenTag + "\n" + dynamicCtx + "\n" + runtimeContextCloseTag + if strings.TrimSpace(userMessage) == "" { + return wrapped + } + return wrapped + "\n\n" + userMessage +} + func (cb *ContextBuilder) BuildMessages( history []providers.Message, summary string, @@ -845,9 +942,11 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov // 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: + // Dynamic parts (summary, and — when position is "system" — the runtime + // block) are appended per request unless the profile suppresses PicoClaw + // system context. + // All system content 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. @@ -855,7 +954,9 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov // - OpenAI-compat passes messages through as-is. staticPrompt, contentBlocks := cb.buildSystemPromptForRequest(req) - // Compose a single system message: static (cached) + dynamic + optional summary. + // Compose a single system message: static (cached) + optional summary + + // optionally the dynamic block (only when position is "system"; by default + // it is carried on the current user message instead — see buildDynamicContext). // Keeping all system content in one message ensures every provider adapter can // extract it correctly (Anthropic adapter -> top-level system param, // Codex -> instructions field). @@ -908,8 +1009,10 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov } dynamicChars := 0 + tailDynamicCtx := "" if !req.SuppressDefaultSystemPrompt { - // Build short dynamic context (time, runtime, session) — changes per request + // Build short dynamic context (runtime, session, sender, time) — changes + // per request. dynamicCtx := cb.buildDynamicContext( req.Channel, req.ChatID, @@ -917,19 +1020,11 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov 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, dynamicCtx) - contentBlocks = append(contentBlocks, promptContentBlock(runtimePart, nil)) + // The summary is emitted BEFORE the dynamic block. It only changes when + // the session is re-summarized, whereas the dynamic block changes every + // request; putting the stabler content first keeps the invalidated + // suffix as short as possible when position is "system". if req.Summary != "" { summaryPart := PromptPart{ ID: "context.summary", @@ -948,6 +1043,32 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov stringParts = append(stringParts, summaryPart.Content) contentBlocks = append(contentBlocks, promptContentBlock(summaryPart, nil)) } + + // Placement. With position "tail" the block is carried on the current + // user message instead of the system message, so the static prompt, + // summary and full history all stay byte-identical between turns and + // backends that only do byte-prefix matching keep their KV cache. + // + // The block must NOT be emitted as a trailing system message: provider + // adapters hoist every system message to the front (Anthropic, Bedrock, + // Gemini, the CLI providers), and openai_responses / antigravity keep + // only the last one, which would silently discard the static prompt. + if cb.dynamicContextOptions().Position == config.DynamicContextPositionTail { + tailDynamicCtx = dynamicCtx + } else { + 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 len(stringParts) == 0 && req.ToolUseFallback { @@ -1010,10 +1131,25 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov // Add current user message. Media-only turns must still be preserved so // multimodal providers receive the uploaded image even when the user sends // no accompanying text. - if strings.TrimSpace(req.CurrentMessage) != "" || len(req.Media) > 0 { - messages = append(messages, userPromptMessage(req.CurrentMessage, req.Media)) - } - if len(messages) == 0 { + // + // When the dynamic context is tail-placed it rides on this message rather + // than becoming a message of its own, which keeps the message count — and + // therefore every len(messages)-1 "current turn starts here" calculation in + // the pipeline — unchanged. + switch { + case strings.TrimSpace(req.CurrentMessage) != "" || len(req.Media) > 0: + messages = append(messages, userPromptMessage( + wrapTailDynamicContext(tailDynamicCtx, req.CurrentMessage), + req.Media, + )) + case strings.TrimSpace(tailDynamicCtx) != "": + // No current user message (e.g. a scheduled or tool-driven turn): + // carry the block on its own trailing user message. + messages = append(messages, userPromptMessage( + wrapTailDynamicContext(tailDynamicCtx, ""), + nil, + )) + case len(messages) == 0: messages = append(messages, userPromptMessage("", nil)) } diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index ef5e6c5de5..55cfe9b496 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -100,13 +100,18 @@ func TestSingleSystemMessage(t *testing.T) { t.Errorf("last message should be user, got %s", msgs[len(msgs)-1].Role) } - // System message must contain identity (static) and time (dynamic) + // System message must contain identity (static). The dynamic time + // context is tail-placed by default, so it rides on the last + // (user) message rather than the system message. sys := msgs[0].Content if !strings.Contains(sys, "picoclaw") { t.Error("system message missing identity") } - if !strings.Contains(sys, "Current Time") { - t.Error("system message missing dynamic time context") + if strings.Contains(sys, "Current Time") { + t.Error("system message must not carry tail-placed time context") + } + if !strings.Contains(msgs[len(msgs)-1].Content, "Current Time") { + t.Error("last message missing dynamic time context") } // Summary handling @@ -169,20 +174,21 @@ func TestBuildMessages_CurrentSenderDynamicContext(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { msgs := cb.BuildMessages(nil, "", "hello", nil, "discord", "chat1", tt.senderID, tt.senderDisplayName) - sys := msgs[0].Content + // Sender info lives in the tail-placed runtime context block. + dyn := runtimeContextBlock(msgs[len(msgs)-1].Content) if tt.wantSection { - if !strings.Contains(sys, "## Current Sender") { - t.Fatalf("system prompt missing Current Sender section:\n%s", sys) + if !strings.Contains(dyn, "## Current Sender") { + t.Fatalf("runtime context missing Current Sender section:\n%s", dyn) } - if !strings.Contains(sys, tt.wantLine) { - t.Fatalf("system prompt missing sender line %q:\n%s", tt.wantLine, sys) + if !strings.Contains(dyn, tt.wantLine) { + t.Fatalf("runtime context missing sender line %q:\n%s", tt.wantLine, dyn) } return } - if strings.Contains(sys, "## Current Sender") { - t.Fatalf("system prompt should omit Current Sender section:\n%s", sys) + if strings.Contains(dyn, "## Current Sender") { + t.Fatalf("runtime context should omit Current Sender section:\n%s", dyn) } }) } @@ -731,8 +737,8 @@ func TestBuildMessages_IncludesMediaOnlyCurrentMessage(t *testing.T) { if userMsg.Role != "user" { t.Fatalf("userMsg.Role = %q, want %q", userMsg.Role, "user") } - if userMsg.Content != "" { - t.Fatalf("userMsg.Content = %q, want empty string", userMsg.Content) + if got := stripRuntimeContext(userMsg.Content); got != "" { + t.Fatalf("userMsg text = %q, want empty string", got) } if len(userMsg.Media) != 1 || userMsg.Media[0] != "data:image/png;base64,abc123" { t.Fatalf("userMsg.Media = %#v, want image payload", userMsg.Media) diff --git a/pkg/agent/dynamic_context_test.go b/pkg/agent/dynamic_context_test.go new file mode 100644 index 0000000000..e34196e52e --- /dev/null +++ b/pkg/agent/dynamic_context_test.go @@ -0,0 +1,250 @@ +package agent + +import ( + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// runtimeContextBlock returns the contents of the tail-placed +// block, or "" when the message carries none. +func runtimeContextBlock(content string) string { + start := strings.Index(content, runtimeContextOpenTag) + if start < 0 { + return "" + } + end := strings.Index(content, runtimeContextCloseTag) + if end < 0 || end < start { + return "" + } + return strings.TrimSpace(content[start+len(runtimeContextOpenTag) : end]) +} + +func TestStripRuntimeContext(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + {"no block passes through", "just text", "just text"}, + {"empty stays empty", "", ""}, + { + "block is removed, user text kept", + "\n## Runtime\nlinux arm64\n\n\nhousekeeping time", + "housekeeping time", + }, + { + "block only leaves nothing", + "\n## Runtime\nlinux arm64\n", + "", + }, + { + "unterminated block is left alone rather than truncating the message", + "\n## Runtime\nlinux arm64\n\nhousekeeping time", + "\n## Runtime\nlinux arm64\n\nhousekeeping time", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := stripRuntimeContext(tt.content); got != tt.want { + t.Fatalf("stripRuntimeContext(%q) = %q, want %q", tt.content, got, tt.want) + } + }) + } +} + +// Tool feedback is shown verbatim in chat, so it must never leak the runtime +// preamble that tail placement prepends to the wire message. +func TestToolFeedbackExplanation_StripsRuntimeContext(t *testing.T) { + messages := []providers.Message{ + {Role: "user", Content: wrapTailDynamicContext( + "## Runtime\nlinux arm64, Go go1.26.5\n\n## Current Time\n2026-08-04 19:13 (Tuesday)", + "trigger a sonarr search", + )}, + } + + got := toolFeedbackExplanationFromMessages(messages) + want := "Continuing the current task.: trigger a sonarr search" + + if got != want { + t.Fatalf("tool feedback explanation = %q, want %q", got, want) + } + if strings.Contains(got, runtimeContextOpenTag) { + t.Fatalf("tool feedback leaked the runtime context block: %q", got) + } +} + +func TestBuildMessages_TailPlacementKeepsSystemPromptStatic(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "IDENTITY.md": "# Identity\nTest agent.", + }) + + cb := NewContextBuilder(tmpDir) + msgs := cb.BuildMessages(nil, "", "hello", nil, "discord", "chat1", "u1", "Alice") + + if len(msgs) != 2 { + t.Fatalf("len(msgs) = %d, want 2", len(msgs)) + } + + sys := msgs[0].Content + for _, marker := range []string{"## Current Time", "## Runtime", "## Current Session", "## Current Sender"} { + if strings.Contains(sys, marker) { + t.Fatalf("system prompt must not contain %q with tail placement:\n%s", marker, sys) + } + } + + user := msgs[len(msgs)-1] + if user.Role != "user" { + t.Fatalf("last message role = %q, want user", user.Role) + } + if stripRuntimeContext(user.Content) != "hello" { + t.Fatalf("user text = %q, want %q", stripRuntimeContext(user.Content), "hello") + } + block := runtimeContextBlock(user.Content) + for _, marker := range []string{"## Current Time", "## Runtime", "## Current Session", "## Current Sender"} { + if !strings.Contains(block, marker) { + t.Fatalf("tail runtime context missing %q:\n%s", marker, block) + } + } +} + +// The whole point of tail placement: a clock tick must leave the system prompt +// and the history byte-identical so a prefix-matching backend keeps its KV cache. +func TestBuildMessages_TailPlacementSystemPromptStableAcrossClockTick(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "IDENTITY.md": "# Identity\nTest agent.", + }) + + cb := NewContextBuilder(tmpDir) + first := cb.BuildMessages(nil, "prior summary", "hello", nil, "discord", "chat1", "u1", "Alice") + second := cb.BuildMessages(nil, "prior summary", "hello", nil, "discord", "chat1", "u1", "Alice") + + if first[0].Content != second[0].Content { + t.Fatalf("system prompt differs between builds:\n%q\n%q", first[0].Content, second[0].Content) + } + if !strings.Contains(first[0].Content, "CONTEXT_SUMMARY:") { + t.Fatal("system prompt missing summary") + } +} + +func TestBuildMessages_SystemPlacementRestoresLegacyLayout(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "IDENTITY.md": "# Identity\nTest agent.", + }) + + cb := NewContextBuilder(tmpDir).WithDynamicContext(config.EffectiveDynamicContext{ + Time: config.DynamicContextTimeMinute, + Position: config.DynamicContextPositionSystem, + }) + msgs := cb.BuildMessages(nil, "", "hello", nil, "discord", "chat1", "u1", "Alice") + + sys := msgs[0].Content + if !strings.Contains(sys, "## Current Time") { + t.Fatalf("system placement must keep the time in the system prompt:\n%s", sys) + } + user := msgs[len(msgs)-1] + if user.Content != "hello" { + t.Fatalf("user content = %q, want unwrapped %q", user.Content, "hello") + } +} + +// Ordering inside the block is by volatility, least volatile first, so that a +// clock tick invalidates as short a suffix as possible under system placement. +func TestBuildDynamicContext_OrdersByVolatility(t *testing.T) { + cb := NewContextBuilder(t.TempDir()) + block := cb.buildDynamicContext("discord", "chat1", "u1", "Alice") + + order := []string{"## Runtime", "## Current Session", "## Current Sender", "## Current Time"} + prev := -1 + for _, marker := range order { + idx := strings.Index(block, marker) + if idx < 0 { + t.Fatalf("dynamic context missing %q:\n%s", marker, block) + } + if idx < prev { + t.Fatalf("dynamic context section %q out of volatility order:\n%s", marker, block) + } + prev = idx + } +} + +func TestFormatCurrentTime_Precision(t *testing.T) { + now := time.Date(2026, 8, 3, 14, 37, 12, 0, time.UTC) + + tests := []struct { + name string + precision config.DynamicContextTime + want string + }{ + {"default is minute", "", "2026-08-03 14:37 (Monday)"}, + {"minute", config.DynamicContextTimeMinute, "2026-08-03 14:37 (Monday)"}, + {"hour truncates minutes", config.DynamicContextTimeHour, "2026-08-03 14:00 (Monday)"}, + {"off omits the time", config.DynamicContextTimeOff, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatCurrentTime(now, tt.precision); got != tt.want { + t.Fatalf("formatCurrentTime() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildDynamicContext_TimeOffOmitsSection(t *testing.T) { + cb := NewContextBuilder(t.TempDir()).WithDynamicContext(config.EffectiveDynamicContext{ + Time: config.DynamicContextTimeOff, + Position: config.DynamicContextPositionTail, + }) + block := cb.buildDynamicContext("discord", "chat1", "", "") + + if strings.Contains(block, "## Current Time") { + t.Fatalf("time off must omit the section:\n%s", block) + } + if !strings.Contains(block, "## Runtime") { + t.Fatalf("time off must keep the runtime section:\n%s", block) + } +} + +// A turn with no user text still needs to carry the block somewhere. +func TestBuildMessages_TailPlacementWithoutCurrentMessage(t *testing.T) { + tmpDir := setupWorkspace(t, nil) + + cb := NewContextBuilder(tmpDir) + msgs := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + History: []providers.Message{{Role: "user", Content: "earlier"}}, + Channel: "pico", + ChatID: "chat-1", + }) + + last := msgs[len(msgs)-1] + if last.Role != "user" { + t.Fatalf("last message role = %q, want user", last.Role) + } + if !strings.Contains(runtimeContextBlock(last.Content), "## Runtime") { + t.Fatalf("trailing message missing runtime context:\n%s", last.Content) + } + if stripRuntimeContext(last.Content) != "" { + t.Fatalf("trailing message should carry no user text, got %q", stripRuntimeContext(last.Content)) + } +} + +func TestSuppressedSystemPromptSkipsDynamicContext(t *testing.T) { + cb := NewContextBuilder(t.TempDir()) + msgs := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "hello", + Channel: "pico", + ChatID: "chat-1", + SuppressDefaultSystemPrompt: true, + }) + + for _, msg := range msgs { + if strings.Contains(msg.Content, runtimeContextOpenTag) { + t.Fatalf("suppressed system prompt must not emit runtime context:\n%s", msg.Content) + } + } +} diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index a2798fd458..ca112feb80 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -145,7 +145,8 @@ func NewAgentInstance( mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25, mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex, ). - WithSplitOnMarker(cfg.Agents.Defaults.SplitOnMarker) + WithSplitOnMarker(cfg.Agents.Defaults.SplitOnMarker). + WithDynamicContext(cfg.Agents.Defaults.ResolveDynamicContext()) agentID := routing.DefaultAgentID agentName := "" diff --git a/pkg/agent/prompt_test.go b/pkg/agent/prompt_test.go index 605e5f4ab9..36906ba0ac 100644 --- a/pkg/agent/prompt_test.go +++ b/pkg/agent/prompt_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "strings" "testing" + + "github.com/sipeed/picoclaw/pkg/config" ) func TestPromptRegistry_RejectsRegisteredSourceWrongPlacement(t *testing.T) { @@ -103,14 +105,19 @@ func TestBuildMessagesFromPrompt_IncludesSystemPromptOverlay(t *testing.T) { if !strings.Contains(messages[0].Content, "Use child-only system instructions.") { t.Fatalf("system prompt missing overlay: %q", messages[0].Content) } - if messages[1].Role != "user" || messages[1].Content != "do child task" { + if messages[1].Role != "user" || stripRuntimeContext(messages[1].Content) != "do child task" { t.Fatalf("messages[1] = %#v, want user task", messages[1]) } } func TestBuildMessagesFromPrompt_AttachesInternalPromptMetadata(t *testing.T) { t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) - cb := NewContextBuilder(t.TempDir()) + // Pin system placement: the runtime block only appears in SystemParts when + // it is not tail-placed, and this test covers its prompt metadata. + cb := NewContextBuilder(t.TempDir()).WithDynamicContext(config.EffectiveDynamicContext{ + Time: config.DynamicContextTimeMinute, + Position: config.DynamicContextPositionSystem, + }) messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ CurrentMessage: "hello", diff --git a/pkg/agent/turn_profile_test.go b/pkg/agent/turn_profile_test.go index eb79d52375..410c4a0c0e 100644 --- a/pkg/agent/turn_profile_test.go +++ b/pkg/agent/turn_profile_test.go @@ -175,8 +175,8 @@ func TestTurnProfile_HistoryOffSuppressesHistoryAndPersistence(t *testing.T) { if len(provider.messages) != 2 { t.Fatalf("provider messages len = %d, want system + current user", len(provider.messages)) } - if provider.messages[1].Content != "new user" { - t.Fatalf("current message = %q, want new user", provider.messages[1].Content) + if got := stripRuntimeContext(provider.messages[1].Content); got != "new user" { + t.Fatalf("current message = %q, want new user", got) } if strings.Contains(provider.messages[0].Content, "old summary") { t.Fatalf("system prompt includes suppressed summary:\n%s", provider.messages[0].Content) @@ -216,8 +216,8 @@ func TestTurnProfile_ProcessMessageUsesEnabledTurnProfile(t *testing.T) { if len(provider.messages) != 2 { t.Fatalf("provider messages len = %d, want system + current user", len(provider.messages)) } - if provider.messages[1].Content != "hello from pico" { - t.Fatalf("current message = %q, want hello from pico", provider.messages[1].Content) + if got := stripRuntimeContext(provider.messages[1].Content); got != "hello from pico" { + t.Fatalf("current message = %q, want hello from pico", got) } } diff --git a/pkg/config/config.go b/pkg/config/config.go index df232f771a..1b2dfec698 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -421,32 +421,33 @@ type ToolFeedbackConfig struct { } type AgentDefaults struct { - Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` - RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` - AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` - Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` - ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` - ModelFallbacks []string `json:"model_fallbacks,omitempty"` - ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` - ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` - MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` - ContextWindow int `json:"context_window,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_WINDOW"` - Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` - MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` - SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` - SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` - MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` - Routing *RoutingConfig `json:"routing,omitempty"` - SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all" - MaxParallelTurns int `json:"max_parallel_turns,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_PARALLEL_TURNS"` // Max concurrent turns (0 or 1 = sequential) - SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"` - ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` - 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"` + Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` + RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` + AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` + Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` + ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` + ModelFallbacks []string `json:"model_fallbacks,omitempty"` + ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` + ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` + MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` + ContextWindow int `json:"context_window,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_WINDOW"` + Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` + MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` + SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` + MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` + Routing *RoutingConfig `json:"routing,omitempty"` + SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all" + MaxParallelTurns int `json:"max_parallel_turns,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_PARALLEL_TURNS"` // Max concurrent turns (0 or 1 = sequential) + SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"` + ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` + 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"` + DynamicContext DynamicContextConfig `json:"dynamic_context,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"` } const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB @@ -1526,6 +1527,9 @@ func LoadConfig(path string) (*Config, error) { if err = cfg.ValidateTurnProfile(); err != nil { return nil, err } + if err = cfg.ValidateDynamicContext(); err != nil { + return nil, err + } cfg.Gateway.Host, err = resolveGatewayHostFromEnv(gatewayHostBeforeEnv) if err != nil { return nil, fmt.Errorf("invalid gateway host: %w", err) diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 96ce5f0f48..22db87ff0d 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -39,6 +39,10 @@ func DefaultConfig() *Config { MaxArgsLength: 300, SeparateMessages: false, }, + DynamicContext: DynamicContextConfig{ + Time: DynamicContextTimeMinute, + Position: DynamicContextPositionTail, + }, SplitOnMarker: false, MaxLLMRetries: 2, LLMRetryBackoffSecs: 2, diff --git a/pkg/config/dynamic_context.go b/pkg/config/dynamic_context.go new file mode 100644 index 0000000000..04935ee630 --- /dev/null +++ b/pkg/config/dynamic_context.go @@ -0,0 +1,135 @@ +package config + +import ( + "fmt" + "strings" +) + +// DynamicContextTime controls the precision of the "## Current Time" line in +// the per-request dynamic context block. +type DynamicContextTime string + +const ( + // DynamicContextTimeMinute emits minute precision (default). + DynamicContextTimeMinute DynamicContextTime = "minute" + // DynamicContextTimeHour rounds the clock down to the hour, widening the + // window over which an identical prompt prefix can be reused. + DynamicContextTimeHour DynamicContextTime = "hour" + // DynamicContextTimeOff omits the current time entirely. + DynamicContextTimeOff DynamicContextTime = "off" +) + +// DynamicContextPosition controls where the per-request dynamic context block +// (time / runtime / session / sender) is placed in the message array. +type DynamicContextPosition string + +const ( + // DynamicContextPositionTail places the block after conversation history, + // immediately before the current user message (default). + // + // Prefix caching is positional: any change before the history invalidates + // every token after it. Keeping the volatile block at the tail means the + // static system prompt plus the whole history stay byte-identical from turn + // to turn, so backends that only do byte-prefix matching (llama.cpp, Ollama) + // hit the cache on every turn instead of missing once per minute. + DynamicContextPositionTail DynamicContextPosition = "tail" + // DynamicContextPositionSystem keeps the block inside the system message, + // the layout used before this option existed. Retained as an escape hatch. + DynamicContextPositionSystem DynamicContextPosition = "system" +) + +// DynamicContextConfig is the user-facing agents.defaults.dynamic_context block. +// Zero values mean "unset" and resolve to the defaults via Effective(). +type DynamicContextConfig struct { + Time DynamicContextTime `json:"time,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_DYNAMIC_CONTEXT_TIME"` + Position DynamicContextPosition `json:"position,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_DYNAMIC_CONTEXT_POSITION"` +} + +// EffectiveDynamicContext is the resolved, runtime-facing form. +type EffectiveDynamicContext struct { + Time DynamicContextTime + Position DynamicContextPosition +} + +// Effective normalizes the time precision, defaulting to minute precision. +// Unknown values are passed through (lowercased) so the validator can report them. +func (t DynamicContextTime) Effective() DynamicContextTime { + normalized := DynamicContextTime(strings.ToLower(strings.TrimSpace(string(t)))) + switch normalized { + case "": + return DynamicContextTimeMinute + default: + return normalized + } +} + +// Effective normalizes the position, defaulting to tail placement. +// Unknown values are passed through (lowercased) so the validator can report them. +func (p DynamicContextPosition) Effective() DynamicContextPosition { + normalized := DynamicContextPosition(strings.ToLower(strings.TrimSpace(string(p)))) + switch normalized { + case "": + return DynamicContextPositionTail + default: + return normalized + } +} + +// DefaultDynamicContext returns the resolved defaults used when no agent +// defaults are available (for example in tests that build a ContextBuilder +// directly). +func DefaultDynamicContext() EffectiveDynamicContext { + return EffectiveDynamicContext{ + Time: DynamicContextTimeMinute, + Position: DynamicContextPositionTail, + } +} + +// ResolveDynamicContext returns the effective dynamic context settings. +// Invalid values fall back to the defaults; LoadConfig rejects them up front +// via ValidateDynamicContext, so this path only matters for programmatically +// constructed configs. +func (d *AgentDefaults) ResolveDynamicContext() EffectiveDynamicContext { + if d == nil { + return DefaultDynamicContext() + } + + resolved := EffectiveDynamicContext{ + Time: d.DynamicContext.Time.Effective(), + Position: d.DynamicContext.Position.Effective(), + } + if err := validateDynamicContext(d.DynamicContext); err != nil { + return DefaultDynamicContext() + } + return resolved +} + +// ValidateDynamicContext validates the agents.defaults.dynamic_context block. +func (c *Config) ValidateDynamicContext() error { + if c == nil { + return nil + } + return validateDynamicContext(c.Agents.Defaults.DynamicContext) +} + +func validateDynamicContext(block DynamicContextConfig) error { + switch block.Time.Effective() { + case DynamicContextTimeMinute, DynamicContextTimeHour, DynamicContextTimeOff: + default: + return fmt.Errorf( + "dynamic_context.time has unsupported value %q (want \"minute\", \"hour\" or \"off\")", + block.Time, + ) + } + + switch block.Position.Effective() { + case DynamicContextPositionTail, DynamicContextPositionSystem: + default: + return fmt.Errorf( + "dynamic_context.position has unsupported value %q (want \"tail\" or \"system\")", + block.Position, + ) + } + + return nil +} diff --git a/pkg/config/dynamic_context_test.go b/pkg/config/dynamic_context_test.go new file mode 100644 index 0000000000..227eaa1487 --- /dev/null +++ b/pkg/config/dynamic_context_test.go @@ -0,0 +1,95 @@ +package config + +import ( + "encoding/json" + "testing" +) + +func TestDynamicContext_Defaults(t *testing.T) { + cfg := DefaultConfig() + if err := cfg.ValidateDynamicContext(); err != nil { + t.Fatalf("ValidateDynamicContext() error = %v", err) + } + + resolved := cfg.Agents.Defaults.ResolveDynamicContext() + if resolved.Time != DynamicContextTimeMinute { + t.Fatalf("default time = %q, want %q", resolved.Time, DynamicContextTimeMinute) + } + if resolved.Position != DynamicContextPositionTail { + t.Fatalf("default position = %q, want %q", resolved.Position, DynamicContextPositionTail) + } +} + +// A config that predates the block must resolve to the tail-placement default, +// not to a zero value. +func TestDynamicContext_OmittedBlockResolvesToDefaults(t *testing.T) { + var defaults AgentDefaults + resolved := defaults.ResolveDynamicContext() + + if resolved.Time != DynamicContextTimeMinute || resolved.Position != DynamicContextPositionTail { + t.Fatalf("resolved = %+v, want minute/tail", resolved) + } +} + +func TestDynamicContext_ParseAndResolve(t *testing.T) { + jsonData := `{ + "agents": { + "defaults": { + "dynamic_context": { + "time": "HOUR", + "position": " system " + } + } + } + }` + + cfg := DefaultConfig() + if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := cfg.ValidateDynamicContext(); err != nil { + t.Fatalf("ValidateDynamicContext() error = %v", err) + } + + resolved := cfg.Agents.Defaults.ResolveDynamicContext() + if resolved.Time != DynamicContextTimeHour { + t.Fatalf("time = %q, want %q", resolved.Time, DynamicContextTimeHour) + } + if resolved.Position != DynamicContextPositionSystem { + t.Fatalf("position = %q, want %q", resolved.Position, DynamicContextPositionSystem) + } +} + +func TestDynamicContext_RejectsUnsupportedValues(t *testing.T) { + tests := []struct { + name string + block DynamicContextConfig + }{ + {"bad time", DynamicContextConfig{Time: "second"}}, + {"bad position", DynamicContextConfig{Position: "middle"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := DefaultConfig() + cfg.Agents.Defaults.DynamicContext = tt.block + if err := cfg.ValidateDynamicContext(); err == nil { + t.Fatalf("ValidateDynamicContext() error = nil, want an error for %+v", tt.block) + } + }) + } +} + +func TestDynamicContext_AcceptsEveryTimePrecision(t *testing.T) { + for _, precision := range []DynamicContextTime{ + DynamicContextTimeMinute, + DynamicContextTimeHour, + DynamicContextTimeOff, + } { + cfg := DefaultConfig() + cfg.Agents.Defaults.DynamicContext.Time = precision + if err := cfg.ValidateDynamicContext(); err != nil { + t.Fatalf("ValidateDynamicContext() error = %v for time %q", err, precision) + } + } +}