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.
This commit is contained in:
lxowalle 2026-04-15 11:54:44 +08:00
parent 20d8c58ca9
commit 7d51e6ce78
15 changed files with 197 additions and 17 deletions

View file

@ -73,7 +73,7 @@ You can also manage installed skills directly from Telegram:
- `/use <skill> <message>`
- `/use <skill>` and then send the actual request in the next message
- `/use clear`
- `/btw <question>` to ask a side question without changing the active session history
- `/btw <question>` 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.

View file

@ -103,7 +103,7 @@ Once skills are installed, you can inspect and force them directly from a chat c
- `/use <skill> <message>` forces a specific skill for a single request.
- `/use <skill>` arms that skill for your next message in the same chat session.
- `/use clear` cancels a pending skill override created by `/use <skill>`.
- `/btw <question>` asks a side question without changing the current session history.
- `/btw <question>` 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:

View file

@ -72,7 +72,7 @@ Vous pouvez aussi gerer les competences installees directement depuis Telegram :
- `/use <skill> <message>`
- `/use <skill>` puis envoyer la vraie requete dans le message suivant
- `/use clear`
- `/btw <question>` pour poser une question annexe sans modifier l'historique actif de la session
- `/btw <question>` 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
</details>

View file

@ -88,7 +88,7 @@ Une fois les compétences installées, vous pouvez aussi les inspecter et les ac
- `/use <skill> <message>` force une compétence pour une seule requête.
- `/use <skill>` 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 <skill>`.
- `/btw <question>` pose une question annexe sans modifier l'historique courant de la session.
- `/btw <question>` 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 :

View file

@ -89,7 +89,7 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
- `/use <skill> <message>` は 1 回のリクエストだけそのスキルを強制します。
- `/use <skill>` は同じチャット内の次のメッセージにそのスキルを予約します。
- `/use clear``/use <skill>` で設定した保留中のスキル上書きを解除します。
- `/btw <question>` は現在のセッション履歴を変更せずに横道の質問を送ります。
- `/btw <question>` は現在のセッション履歴を変更せずに即時の横道の質問を送ります。`/btw` はツールなしの直接質問として処理され、通常のツール実行フローには入りません。
例:

View file

@ -71,7 +71,7 @@ Anda juga boleh mengurus skill yang dipasang terus dari Telegram:
- `/use <skill> <message>`
- `/use <skill>` kemudian hantar permintaan sebenar dalam mesej seterusnya
- `/use clear`
- `/btw <question>` untuk bertanya soalan sampingan tanpa mengubah sejarah sesi aktif
- `/btw <question>` 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.

View file

@ -71,7 +71,7 @@ Selepas skill dipasang, anda boleh menyemak dan memaksanya terus dari saluran ch
- `/use <skill> <message>` memaksa satu skill untuk satu permintaan sahaja.
- `/use <skill>` menyediakan skill itu untuk mesej anda yang seterusnya dalam chat yang sama.
- `/use clear` membatalkan skill override tertunda yang dibuat melalui `/use <skill>`.
- `/btw <question>` bertanya soalan sampingan tanpa mengubah sejarah sesi semasa.
- `/btw <question>` bertanya soalan sampingan segera tanpa mengubah sejarah sesi semasa. `/btw` dikendalikan sebagai pertanyaan langsung tanpa tool dan tidak memasuki aliran pelaksanaan tool biasa.
Contoh:

View file

@ -72,7 +72,7 @@ Voce tambem pode gerenciar skills instaladas diretamente pelo Telegram:
- `/use <skill> <message>`
- `/use <skill>` e depois enviar a solicitacao real na proxima mensagem
- `/use clear`
- `/btw <question>` para fazer uma pergunta lateral sem alterar o historico ativo da sessao
- `/btw <question>` 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
</details>

View file

@ -89,7 +89,7 @@ Depois que as skills estiverem instaladas, voce pode inspeciona-las e aplica-las
- `/use <skill> <message>` força uma skill para uma unica requisicao.
- `/use <skill>` prepara essa skill para a sua proxima mensagem no mesmo chat.
- `/use clear` cancela uma substituicao pendente criada por `/use <skill>`.
- `/btw <question>` faz uma pergunta lateral sem alterar o historico atual da sessao.
- `/btw <question>` 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:

View file

@ -72,7 +72,7 @@ Ban cung co the quan ly skill da cai dat truc tiep tu Telegram:
- `/use <skill> <message>`
- `/use <skill>` roi gui yeu cau that o tin nhan tiep theo
- `/use clear`
- `/btw <question>` de hoi them mot cau ngoai le ma khong thay doi lich su phien dang hoat dong
- `/btw <question>` 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
</details>

View file

@ -89,7 +89,7 @@ Sau khi cai dat skill, ban co the xem va ep dung truc tiep tu kenh chat:
- `/use <skill> <message>` ep dung mot skill cho duy nhat mot yeu cau.
- `/use <skill>` 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 <skill>`.
- `/btw <question>` dat cau hoi phu ma khong thay doi lich su phien hien tai.
- `/btw <question>` 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:

View file

@ -76,7 +76,7 @@ Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行
- `/use <skill> <message>`
- `/use <skill>`,然后在下一条消息里发送真正的请求
- `/use clear`
- `/btw <question>`,用于发起不改动当前会话历史的旁支提问
- `/btw <question>`,用于发起一个不改动当前会话历史的即时旁支提问`/btw` 会按一次无工具的直接问答处理,不会进入常规的工具执行流程
</details>

View file

@ -101,7 +101,7 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
- `/use <skill> <message>`:只对当前这一条请求强制使用指定技能。
- `/use <skill>`:为同一会话中的下一条消息预先启用该技能。
- `/use clear`:取消通过 `/use <skill>` 设置的待应用技能。
- `/btw <question>`:发起一个旁支提问,且不改动当前会话历史。
- `/btw <question>`:发起一个即时的旁支提问,且不改动当前会话历史。`/btw` 会按一次无工具的直接问答处理,不会进入常规的工具执行流程。
示例:

View file

@ -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

View file

@ -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