From 7d51e6ce78ab3e154e375c26b66e5a43ee100f11 Mon Sep 17 00:00:00 2001 From: lxowalle Date: Wed, 15 Apr 2026 11:54:44 +0800 Subject: [PATCH] fix(agent): run /btw through hooks Keep /btw aligned with the standard LLM hook pipeline so side questions do not bypass request and response interception. Clarify across docs that /btw remains an immediate, no-tool query that does not persist session history. --- docs/chat-apps.md | 2 +- docs/configuration.md | 2 +- docs/fr/chat-apps.md | 2 +- docs/fr/configuration.md | 2 +- docs/ja/configuration.md | 2 +- docs/my/chat-apps.md | 2 +- docs/my/configuration.md | 2 +- docs/pt-br/chat-apps.md | 2 +- docs/pt-br/configuration.md | 2 +- docs/vi/chat-apps.md | 2 +- docs/vi/configuration.md | 2 +- docs/zh/chat-apps.md | 2 +- docs/zh/configuration.md | 2 +- pkg/agent/hooks_test.go | 78 +++++++++++++++++++++++++ pkg/agent/loop.go | 110 ++++++++++++++++++++++++++++++++++-- 15 files changed, 197 insertions(+), 17 deletions(-) diff --git a/docs/chat-apps.md b/docs/chat-apps.md index d0d55da5f..698633642 100644 --- a/docs/chat-apps.md +++ b/docs/chat-apps.md @@ -73,7 +73,7 @@ You can also manage installed skills directly from Telegram: - `/use ` - `/use ` and then send the actual request in the next message - `/use clear` -- `/btw ` to ask a side question without changing the active session history +- `/btw ` to ask an immediate side question without changing the active session history; `/btw` is handled as a no-tool query and does not enter the normal tool-execution flow **4. Advanced Formatting** You can set use_markdown_v2: true to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks. diff --git a/docs/configuration.md b/docs/configuration.md index 91ff2e1d2..96d5c35a3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -103,7 +103,7 @@ Once skills are installed, you can inspect and force them directly from a chat c - `/use ` forces a specific skill for a single request. - `/use ` arms that skill for your next message in the same chat session. - `/use clear` cancels a pending skill override created by `/use `. -- `/btw ` asks a side question without changing the current session history. +- `/btw ` asks an immediate side question without changing the current session history. `/btw` is handled as a no-tool query and does not enter the normal tool-execution flow. Examples: diff --git a/docs/fr/chat-apps.md b/docs/fr/chat-apps.md index 240d09024..35330ed92 100644 --- a/docs/fr/chat-apps.md +++ b/docs/fr/chat-apps.md @@ -72,7 +72,7 @@ Vous pouvez aussi gerer les competences installees directement depuis Telegram : - `/use ` - `/use ` puis envoyer la vraie requete dans le message suivant - `/use clear` -- `/btw ` pour poser une question annexe sans modifier l'historique actif de la session +- `/btw ` pour poser une question annexe immediate sans modifier l'historique actif de la session ; `/btw` est traite comme une requete directe sans outils et n'entre pas dans le flux normal d'execution des outils diff --git a/docs/fr/configuration.md b/docs/fr/configuration.md index 03f5cdbdd..b26b8c4f7 100644 --- a/docs/fr/configuration.md +++ b/docs/fr/configuration.md @@ -88,7 +88,7 @@ Une fois les compétences installées, vous pouvez aussi les inspecter et les ac - `/use ` force une compétence pour une seule requête. - `/use ` prépare cette compétence pour votre prochain message dans la meme conversation. - `/use clear` annule une surcharge de compétence en attente creee via `/use `. -- `/btw ` pose une question annexe sans modifier l'historique courant de la session. +- `/btw ` pose une question annexe immediate sans modifier l'historique courant de la session. `/btw` est traite comme une requete directe sans outils et n'entre pas dans le flux normal d'execution des outils. Exemples : diff --git a/docs/ja/configuration.md b/docs/ja/configuration.md index d5d5c1058..bf2392585 100644 --- a/docs/ja/configuration.md +++ b/docs/ja/configuration.md @@ -89,7 +89,7 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills - `/use ` は 1 回のリクエストだけそのスキルを強制します。 - `/use ` は同じチャット内の次のメッセージにそのスキルを予約します。 - `/use clear` は `/use ` で設定した保留中のスキル上書きを解除します。 -- `/btw ` は現在のセッション履歴を変更せずに横道の質問を送ります。 +- `/btw ` は現在のセッション履歴を変更せずに即時の横道の質問を送ります。`/btw` はツールなしの直接質問として処理され、通常のツール実行フローには入りません。 例: diff --git a/docs/my/chat-apps.md b/docs/my/chat-apps.md index e256e6596..531c19cbb 100644 --- a/docs/my/chat-apps.md +++ b/docs/my/chat-apps.md @@ -71,7 +71,7 @@ Anda juga boleh mengurus skill yang dipasang terus dari Telegram: - `/use ` - `/use ` kemudian hantar permintaan sebenar dalam mesej seterusnya - `/use clear` -- `/btw ` untuk bertanya soalan sampingan tanpa mengubah sejarah sesi aktif +- `/btw ` untuk bertanya soalan sampingan segera tanpa mengubah sejarah sesi aktif; `/btw` dikendalikan sebagai pertanyaan langsung tanpa tool dan tidak memasuki aliran pelaksanaan tool biasa **4. Pemformatan Lanjutan** Anda boleh menetapkan `use_markdown_v2: true` untuk mengaktifkan pilihan pemformatan yang lebih maju. Ini membolehkan bot menggunakan keseluruhan set ciri Telegram MarkdownV2, termasuk gaya bersarang, spoiler, dan blok lebar tetap tersuai. diff --git a/docs/my/configuration.md b/docs/my/configuration.md index cd8363571..75bdd71a6 100644 --- a/docs/my/configuration.md +++ b/docs/my/configuration.md @@ -71,7 +71,7 @@ Selepas skill dipasang, anda boleh menyemak dan memaksanya terus dari saluran ch - `/use ` memaksa satu skill untuk satu permintaan sahaja. - `/use ` menyediakan skill itu untuk mesej anda yang seterusnya dalam chat yang sama. - `/use clear` membatalkan skill override tertunda yang dibuat melalui `/use `. -- `/btw ` bertanya soalan sampingan tanpa mengubah sejarah sesi semasa. +- `/btw ` bertanya soalan sampingan segera tanpa mengubah sejarah sesi semasa. `/btw` dikendalikan sebagai pertanyaan langsung tanpa tool dan tidak memasuki aliran pelaksanaan tool biasa. Contoh: diff --git a/docs/pt-br/chat-apps.md b/docs/pt-br/chat-apps.md index a5360a865..5d7e5990b 100644 --- a/docs/pt-br/chat-apps.md +++ b/docs/pt-br/chat-apps.md @@ -72,7 +72,7 @@ Voce tambem pode gerenciar skills instaladas diretamente pelo Telegram: - `/use ` - `/use ` e depois enviar a solicitacao real na proxima mensagem - `/use clear` -- `/btw ` para fazer uma pergunta lateral sem alterar o historico ativo da sessao +- `/btw ` para fazer uma pergunta lateral imediata sem alterar o historico ativo da sessao; `/btw` e tratado como uma consulta direta sem ferramentas e nao entra no fluxo normal de execucao de ferramentas diff --git a/docs/pt-br/configuration.md b/docs/pt-br/configuration.md index ab5b33d29..7bf5f4026 100644 --- a/docs/pt-br/configuration.md +++ b/docs/pt-br/configuration.md @@ -89,7 +89,7 @@ Depois que as skills estiverem instaladas, voce pode inspeciona-las e aplica-las - `/use ` força uma skill para uma unica requisicao. - `/use ` prepara essa skill para a sua proxima mensagem no mesmo chat. - `/use clear` cancela uma substituicao pendente criada por `/use `. -- `/btw ` faz uma pergunta lateral sem alterar o historico atual da sessao. +- `/btw ` faz uma pergunta lateral imediata sem alterar o historico atual da sessao. `/btw` e tratado como uma consulta direta sem ferramentas e nao entra no fluxo normal de execucao de ferramentas. Exemplos: diff --git a/docs/vi/chat-apps.md b/docs/vi/chat-apps.md index 45fff5c0c..5dc4f8f01 100644 --- a/docs/vi/chat-apps.md +++ b/docs/vi/chat-apps.md @@ -72,7 +72,7 @@ Ban cung co the quan ly skill da cai dat truc tiep tu Telegram: - `/use ` - `/use ` roi gui yeu cau that o tin nhan tiep theo - `/use clear` -- `/btw ` de hoi them mot cau ngoai le ma khong thay doi lich su phien dang hoat dong +- `/btw ` de hoi them mot cau ngoai le ngay lap tuc ma khong thay doi lich su phien dang hoat dong; `/btw` duoc xu ly nhu mot truy van truc tiep khong dung cong cu va khong di vao luong thuc thi cong cu thong thuong diff --git a/docs/vi/configuration.md b/docs/vi/configuration.md index 0e11ee76c..ea897bc28 100644 --- a/docs/vi/configuration.md +++ b/docs/vi/configuration.md @@ -89,7 +89,7 @@ Sau khi cai dat skill, ban co the xem va ep dung truc tiep tu kenh chat: - `/use ` ep dung mot skill cho duy nhat mot yeu cau. - `/use ` dat san skill do cho tin nhan tiep theo trong cung cuoc tro chuyen. - `/use clear` huy skill override dang cho duoc tao boi `/use `. -- `/btw ` dat cau hoi phu ma khong thay doi lich su phien hien tai. +- `/btw ` dat cau hoi phu ngay lap tuc ma khong thay doi lich su phien hien tai. `/btw` duoc xu ly nhu mot truy van truc tiep khong dung cong cu va khong di vao luong thuc thi cong cu thong thuong. Vi du: diff --git a/docs/zh/chat-apps.md b/docs/zh/chat-apps.md index ffed01418..bb71e7c1c 100644 --- a/docs/zh/chat-apps.md +++ b/docs/zh/chat-apps.md @@ -76,7 +76,7 @@ Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行 - `/use ` - `/use `,然后在下一条消息里发送真正的请求 - `/use clear` -- `/btw `,用于发起不改动当前会话历史的旁支提问 +- `/btw `,用于发起一个不改动当前会话历史的即时旁支提问;`/btw` 会按一次无工具的直接问答处理,不会进入常规的工具执行流程 diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 43d0004fd..9a8d39262 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -101,7 +101,7 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills - `/use `:只对当前这一条请求强制使用指定技能。 - `/use `:为同一会话中的下一条消息预先启用该技能。 - `/use clear`:取消通过 `/use ` 设置的待应用技能。 -- `/btw `:发起一个旁支提问,且不改动当前会话历史。 +- `/btw `:发起一个即时的旁支提问,且不改动当前会话历史。`/btw` 会按一次无工具的直接问答处理,不会进入常规的工具执行流程。 示例: diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go index cf0d03c03..381c9979f 100644 --- a/pkg/agent/hooks_test.go +++ b/pkg/agent/hooks_test.go @@ -230,6 +230,84 @@ func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) { } } +func TestAgentLoop_BtwCommand_UsesLLMHooks(t *testing.T) { + provider := &llmHookTestProvider{} + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + hook := &llmObserverHook{eventCh: make(chan Event, 1)} + if err := al.MountHook(NamedHook("llm-observer", hook)); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + response, handled := al.handleCommand(context.Background(), bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "cli", + ChatID: "direct", + ChatType: "direct", + SenderID: "hook-user", + }, + Content: "/btw hello", + }, agent, &processOptions{ + Dispatch: DispatchRequest{ + SessionKey: "session-1", + InboundContext: &bus.InboundContext{ + Channel: "cli", + ChatID: "direct", + ChatType: "direct", + SenderID: "hook-user", + }, + RouteResult: &routing.ResolvedRoute{ + AgentID: "main", + Channel: "cli", + AccountID: routing.DefaultAccountID, + SessionPolicy: routing.SessionPolicy{ + Dimensions: []string{"sender"}, + }, + MatchedBy: "default", + }, + SessionScope: &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: "main", + Channel: "cli", + Account: routing.DefaultAccountID, + Dimensions: []string{"sender"}, + Values: map[string]string{ + "sender": "hook-user", + }, + }, + UserMessage: "/btw hello", + }, + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + SenderID: "hook-user", + SenderDisplayName: "Hook User", + }) + if !handled { + t.Fatal("expected /btw command to be handled") + } + if response != "hooked content" { + t.Fatalf("expected hooked content, got %q", response) + } + + provider.mu.Lock() + lastModel := provider.lastModel + provider.mu.Unlock() + if lastModel != "hook-model" { + t.Fatalf("expected model hook-model, got %q", lastModel) + } + if hook.lastInbound == nil { + t.Fatal("expected hook to receive inbound context") + } + if hook.lastInbound.Channel != "cli" || hook.lastInbound.SenderID != "hook-user" { + t.Fatalf("hook inbound context = %+v", hook.lastInbound) + } + if hook.lastInbound.ChatID != "direct" { + t.Fatalf("hook inbound chat ID = %q, want direct", hook.lastInbound.ChatID) + } +} + type toolHookProvider struct { mu sync.Mutex calls int diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index bb2bf9ef0..cfb571768 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1613,6 +1613,47 @@ func (al *AgentLoop) askSideQuestion( return provider.Chat(ctx, messages, nil, model, llmOpts) } + turnCtx := newTurnContext(nil, nil, nil) + if opts != nil { + turnCtx = newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope) + } + llmModel := activeModel + if al.hooks != nil { + llmReq, decision := al.hooks.BeforeLLM(ctx, &LLMHookRequest{ + Meta: EventMeta{ + Source: "askSideQuestion", + TracePath: "turn.llm.request", + turnContext: cloneTurnContext(turnCtx), + }, + Context: cloneTurnContext(turnCtx), + Model: llmModel, + Messages: messages, + Tools: nil, + Options: llmOpts, + GracefulTerminal: false, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmReq != nil { + llmModel = llmReq.Model + messages = llmReq.Messages + llmOpts = llmReq.Options + } + case HookActionAbortTurn: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason) + case HookActionHardAbort: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason) + } + } + if len(activeCandidates) > 1 && al.fallback != nil { fbResult, err := al.fallback.Execute( ctx, @@ -1631,20 +1672,81 @@ func (al *AgentLoop) askSideQuestion( if fbResult.Response == nil { return "", nil } - content := fbResult.Response.Content - if content == "" && fbResult.Response.ReasoningContent != "" { - content = fbResult.Response.ReasoningContent + response := fbResult.Response + if al.hooks != nil { + llmResp, decision := al.hooks.AfterLLM(ctx, &LLMHookResponse{ + Meta: EventMeta{ + Source: "askSideQuestion", + TracePath: "turn.llm.response", + turnContext: cloneTurnContext(turnCtx), + }, + Context: cloneTurnContext(turnCtx), + Model: llmModel, + Response: response, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmResp != nil && llmResp.Response != nil { + response = llmResp.Response + } + case HookActionAbortTurn: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during after_llm: %s", reason) + case HookActionHardAbort: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during after_llm: %s", reason) + } + } + content := response.Content + if content == "" && response.ReasoningContent != "" { + content = response.ReasoningContent } return content, nil } - resp, err := callProvider(ctx, activeProvider, activeModel) + resp, err := callProvider(ctx, activeProvider, llmModel) if err != nil { return "", err } if resp == nil { return "", nil } + if al.hooks != nil { + llmResp, decision := al.hooks.AfterLLM(ctx, &LLMHookResponse{ + Meta: EventMeta{ + Source: "askSideQuestion", + TracePath: "turn.llm.response", + turnContext: cloneTurnContext(turnCtx), + }, + Context: cloneTurnContext(turnCtx), + Model: llmModel, + Response: resp, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmResp != nil && llmResp.Response != nil { + resp = llmResp.Response + } + case HookActionAbortTurn: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during after_llm: %s", reason) + case HookActionHardAbort: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during after_llm: %s", reason) + } + } content := resp.Content if content == "" && resp.ReasoningContent != "" { content = resp.ReasoningContent