Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# AGENTS.md

## 当前主线任务

当前正在完善 `useMessage` 的工具调用确认流程,核心目标是把工具调用从“运行时 Promise 等待确认”改为“通过可序列化的消息状态和 `role: 'tool'` 结果消息继续流程”。

这条线的后续重点是:给 `useMessage` 增加一个等待工具结果提交的 processing 状态,让工具确认流程可以被存储、恢复,并支持 pause/resume。

## 已完成内容

- 新增 `submitToolResult(message | message[])` API。
- core:`packages/kit/src/message/core/engine.ts`
- 类型:`packages/kit/src/message/types.ts`
- Vue 返回值:`packages/kit/src/vue/message/useMessage.ts`
- Vue 类型:`packages/kit/src/vue/message/types.ts`
- `submitToolResult` 只接受 `role: 'tool'` 消息。
- 提交 tool result 后,会检查最近一个 assistant message 的 `tool_calls` 是否都已有结果。
- 全部 tool result 齐全后,kit 会自动继续下一轮请求。
- `toolPlugin.confirmToolCall` 已改为布尔判断:
- 返回 `true`:标记工具调用为 `awaiting-approval`,不创建空 tool message,不执行 `callTool`。
- 返回 `false` 或未传:按原自动工具调用逻辑执行 `callTool`。
- Vue 侧 `toolPlugin` 已同步简化,不再包装旧的 `ToolCallDecision` Promise 等待逻辑。
- Tool 渲染器已支持 `awaiting-approval` 状态下展示“允许 / 拒绝”按钮。
- 按钮点击通过 Bubble 的通用 `state-change` 事件发出 `toolCallDecision`,业务侧再调用 `submitToolResult`。
- 文档 demo 已抽成组件:
- `docs/demos/tools/message/ToolCallConfirm.ts`
- `docs/demos/tools/message/ToolCallConfirm.vue`
- 已新增后续设计 TODO:
- `packages/kit/TODO.md`

## 后续待做

主要 TODO 在 `packages/kit/TODO.md`。

下一步建议实现:

1. 扩展 `RequestProcessingState`,增加 `awaiting-tool-results`。
2. 当 `toolPlugin` 发现有工具调用需要确认时:
- 设置对应 `state.toolCall[id].status = 'awaiting-approval'`。
- 设置全局 `processingState = 'awaiting-tool-results'`。
- 停止本轮自动 `requestNext`。
3. `submitToolResult` 提交部分 tool result 后:
- 更新对应 tool call 状态。
- 如果仍有缺失结果,保持 `awaiting-tool-results`。
- 如果全部结果齐全,进入下一轮请求。
4. 恢复会话时,根据最近一个 assistant message 的 `tool_calls` 和后续 tool messages 差集推导是否仍在等待工具结果。
5. UI 可根据 `processingState === 'awaiting-tool-results'` 展示全局等待状态。

## 关键设计约束

- 不要恢复旧的 `await confirmToolCall` 内存等待方案。页面刷新后 Promise/resolver 无法恢复。
- deny 也应该是一条 `role: 'tool'` 消息,通过 `submitToolResult` 提交。
- 多个并行 tool call 可以部分提交,但只有全部结果齐全后才能继续请求。
- `submitToolResult` 应只针对最近一个带 `tool_calls` 的 assistant message 生效。
- 如果最近一个 assistant message 没有 `tool_calls`,提交 tool result 应拒绝或警告。
- 不要通过监听变量触发工具调用,优先使用 Bubble 现有 `state-change` 事件机制。
- 文档构建很慢,除非明确要求,不要运行 docs build。

## 常用验证命令

```bash
pnpm -F @opentiny/tiny-robot-kit test -- src/vue/message/useMessage.test.ts
pnpm -F @opentiny/tiny-robot-kit build
```

当前相关单测已覆盖:

- 等待外部提交 tool result 后再继续请求。
- 并行 tool calls 支持部分提交,全部提交后再继续。
- Vue 侧 toolPlugin 仍能正常走自动工具调用流程。

## 相关文件

- `packages/kit/src/message/core/engine.ts`
- `packages/kit/src/message/plugins/toolPlugin.ts`
- `packages/kit/src/vue/message/plugins/toolPlugin.ts`
- `packages/kit/src/vue/message/useMessage.ts`
- `packages/kit/src/vue/message/useMessage.test.ts`
- `packages/components/src/bubble/renderers/Tool.vue`
- `packages/components/src/bubble/composables/useToolCall.ts`
- `docs/demos/tools/message/ToolCallConfirm.ts`
- `docs/demos/tools/message/ToolCallConfirm.vue`
- `docs/src/tools/message.md`
- `packages/kit/TODO.md`

## 工作区注意事项

当前工作区可能包含与这条任务无关的已有改动,例如:

- `.gitignore`
- `docs/.vitepress/config.mts`
- `packages/playground/vite.config.ts`

继续工作时不要随意 revert 这些文件,除非明确知道它们属于当前任务并且需要处理。
125 changes: 125 additions & 0 deletions docs/demos/bubble/tool-choice.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { Bubble } from '@opentiny/tiny-robot'
import { IconAi } from '@opentiny/tiny-robot-svgs'
import { h, ref } from 'vue'

const aiAvatar = h(IconAi, { style: { fontSize: '32px' } })

const toolCalls = [
{
id: 'tool_call_id_01',
type: 'function',
function: {
name: '天气查询',
arguments: JSON.stringify({ city: '深圳' }),
},
},
]

const state = ref<{
toolCall: Record<string, { status?: string; open?: boolean }>
}>({
toolCall: {
tool_call_id_01: { status: 'awaiting-approval' },
},
})

const selectedChoice = ref<'allow' | 'deny' | undefined>()
const choiceText = {
allow: '允许',
deny: '拒绝',
}

const handleReset = () => {
selectedChoice.value = undefined
state.value.toolCall.tool_call_id_01.status = 'awaiting-approval'
}

const handleStateChange = (payload: { key: string; value: unknown }) => {
if (payload.key === 'toolCallDecision') {
const value = payload.value as {
toolCallId: string
decision: { action: 'allow' | 'deny' }
}

selectedChoice.value = value.decision.action
state.value.toolCall[value.toolCallId].status = value.decision.action === 'allow' ? 'success' : 'denied'
return
}

if (payload.key === 'toolCall') {
state.value.toolCall = payload.value as typeof state.value.toolCall
}
}
</script>

<template>
<div style="display: flex; flex-direction: column; gap: 12px">
<div class="demo-toolbar">
<button class="reset-button" type="button" @click="handleReset">重置工具状态</button>
<span v-if="selectedChoice" class="choice-status" :data-choice="selectedChoice">
<span class="choice-dot"></span>
<span class="choice-label">已选择</span>
<strong>{{ choiceText[selectedChoice] }}</strong>
</span>
</div>

<Bubble
content="查询天气前需要确认。"
:tool_calls="toolCalls"
:avatar="aiAvatar"
:state="state"
@state-change="handleStateChange"
></Bubble>
</div>
</template>

<style scoped>
.demo-toolbar {
display: flex;
align-items: center;
gap: 12px;
}

.reset-button {
height: 28px;
padding: 0 12px;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
border: 0;
border-radius: 6px;
cursor: pointer;
}

.reset-button:hover {
background: var(--vp-c-default-soft);
}

.choice-status {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--vp-c-text-2);
font-size: 13px;
}

.choice-status strong {
color: var(--vp-c-text-1);
font-weight: 600;
}

.choice-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--vp-c-green-2);
}

.choice-status[data-choice='deny'] .choice-dot {
background: var(--vp-c-red-2);
}

:deep(.tr-bubble__tool-call) {
min-width: 350px;
}
</style>
107 changes: 107 additions & 0 deletions docs/demos/tools/message/ToolCallConfirm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { ChatCompletion, MessageRequestBody, Tool } from '@opentiny/tiny-robot-kit'
import { toolPlugin, useMessage } from '@opentiny/tiny-robot-kit'

const getTools = async (): Promise<Tool[]> => [
{
type: 'function',
function: {
name: 'search_private_docs',
description: '搜索内部文档。',
parameters: {
type: 'object',
properties: { keyword: { type: 'string' } },
required: ['keyword'],
},
},
},
]

export function useMessageToolCallConfirm() {
return useMessage({
responseProvider: mockStreamWithConfirmTools,
plugins: [
toolPlugin({
getTools,
confirmToolCall() {
return true
},
callTool: async (toolCall) => {
const args = JSON.parse(toolCall.function?.arguments || '{}')
return `已搜索内部文档:${args.keyword}`
},
}),
],
initialMessages: [
{
content: '发送任意消息后,示例会模拟一次需要确认的工具调用。',
role: 'assistant',
},
],
})
}

async function* mockStreamWithConfirmTools(
requestBody: MessageRequestBody,
abortSignal: AbortSignal,
): AsyncGenerator<ChatCompletion> {
const msgs = requestBody.messages || []
const last = msgs[msgs.length - 1]
const id = 'mock-confirm-tool-' + Date.now()

if (last?.role === 'tool') {
const text = '工具调用已处理完成。'
for (let i = 0; i < text.length && !abortSignal.aborted; i++) {
await new Promise((resolve) => setTimeout(resolve, 40))
const content = text[i]
yield {
id,
object: 'chat.completion.chunk',
created: Math.floor(Date.now() / 1000),
model: 'mock',
system_fingerprint: null,
choices: [
{
index: 0,
message: undefined,
delta: i === 0 ? { role: 'assistant', content } : { content },
finish_reason: i === text.length - 1 ? 'stop' : null,
logprobs: null,
},
],
}
}
return
}

await new Promise((resolve) => setTimeout(resolve, 300))
yield {
id,
object: 'chat.completion.chunk',
created: Math.floor(Date.now() / 1000),
model: 'mock',
system_fingerprint: null,
choices: [
{
index: 0,
message: undefined,
delta: {
role: 'assistant',
content: '这个操作需要确认后再执行。',
tool_calls: [
{
index: 0,
id: 'call_confirm_search_1',
type: 'function',
function: {
name: 'search_private_docs',
arguments: '{"keyword":"Q3 roadmap"}',
},
},
],
},
finish_reason: 'tool_calls',
logprobs: null,
},
],
}
}
Loading
Loading