mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
fix(agent): 流式并行 tool_call 按 JSON index 分桶,修复空参数调用
【根因】内核流式解析层丢弃了上游 SSE 分片的 OpenAI index 字段:
- openAIToolCall 结构体无 index 字段,JSON 解析即丢
- homed 的 openai.lua 转换为扁平结构时同样未透传 index
- accumulateStream 退而用 Go range slice 序号做累积桶 key,
但每个 SSE chunk 只含一个 tool_call 元素,序号恒为 0
于是并行多工具调用(index=0,1,2,3)的所有分片全部写入同一个桶:
name 相互覆盖、args 碎片混拼成非法 JSON → parseToolArgsJSON
失败返回空 map → 工具以空参数被调用(spawn_child 报'请提供 task'、
cmd_run 报'command is required'等),agent 只能串行重试自愈。
单工具场景只有一个 index 无污染,故简单请求一直正常;
pi 直连同一 llmsproxy 正常(其实现标准按 index 累积)。
【修复】
- ToolCall 增加 StreamIndex(json:stream_index),openAIToolCall
解析上游 index 并透传;openai.lua 输出 stream_index 字段
- accumulateStream 以 tc.StreamIndex 为累积 key
- flushToolCall 区分三种空参:未收到分片/碎片非合法 JSON/合法空
对象({}),分别打诊断日志,避免误报
- 回归测试 TestAccumulateStreamParallelToolCallsByIndex 模拟
4 路并行分片流验证按 index 正确分组与参数完整性
另含 spawn_child max_turns 参数、child_result 运行中状态区分、
provider 层非流式空参诊断日志。
This commit is contained in:
@ -7,6 +7,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
@ -191,6 +193,10 @@ type ToolCall struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Arguments map[string]interface{} `json:"arguments"`
|
Arguments map[string]interface{} `json:"arguments"`
|
||||||
RawArguments string `json:"raw_arguments,omitempty"` // 流式分片原始 JSON 字符串
|
RawArguments string `json:"raw_arguments,omitempty"` // 流式分片原始 JSON 字符串
|
||||||
|
// StreamIndex 是上游流式 tool_call 的 OpenAI index 字段(并行多工具调用
|
||||||
|
// 时同一轮的分片用它区分归属)。lua 适配器以 stream_index 键透传;
|
||||||
|
// 仅内核流式累积内部使用,不序列化到对外 API。
|
||||||
|
StreamIndex int `json:"stream_index,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiToolCall struct {
|
type apiToolCall struct {
|
||||||
@ -403,6 +409,14 @@ func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) (
|
|||||||
return nil, fmt.Errorf("unmarshal unified response: %w (body: %s)", err, unifiedJSON)
|
return nil, fmt.Errorf("unmarshal unified response: %w (body: %s)", err, unifiedJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 诊断:tool_calls 存在但参数为空——上游/适配器丢参数,打印原始响应片段定位
|
||||||
|
for _, tc := range result.ToolCalls {
|
||||||
|
if len(tc.Arguments) == 0 && tc.RawArguments == "" {
|
||||||
|
log.Printf("[provider:%s] tool_call %s (%s) has empty arguments; raw body head: %s",
|
||||||
|
p.name, tc.Name, tc.ID, string(rawResp[:min(len(rawResp), 400)]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -551,6 +565,7 @@ func parseOpenAICompatibleSSEBody(raw []byte) (*CompletionResponse, bool) {
|
|||||||
type openAIToolCall struct {
|
type openAIToolCall struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
Index int `json:"index"`
|
||||||
Function struct {
|
Function struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Arguments interface{} `json:"arguments"`
|
Arguments interface{} `json:"arguments"`
|
||||||
@ -584,6 +599,7 @@ func normalizeOpenAIToolCalls(raw []openAIToolCall) []ToolCall {
|
|||||||
Name: name,
|
Name: name,
|
||||||
Arguments: parseToolArguments(argsRaw),
|
Arguments: parseToolArguments(argsRaw),
|
||||||
RawArguments: rawArgsString(argsRaw),
|
RawArguments: rawArgsString(argsRaw),
|
||||||
|
StreamIndex: tc.Index,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@ -629,6 +645,7 @@ func normalizeStreamToolCalls(raw []openAIToolCall) []ToolCall {
|
|||||||
Type: typ,
|
Type: typ,
|
||||||
Name: name,
|
Name: name,
|
||||||
RawArguments: rawArgsString(argsRaw),
|
RawArguments: rawArgsString(argsRaw),
|
||||||
|
StreamIndex: tc.Index,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@ -856,6 +873,7 @@ func (p *LuaAdaptedProvider) ChatStream(ctx context.Context, req *CompletionRequ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugSSE := os.Getenv("HOMED_DEBUG_SSE") == "1"
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := strings.TrimSpace(scanner.Text())
|
line := strings.TrimSpace(scanner.Text())
|
||||||
if line == "" || !strings.HasPrefix(line, "data:") {
|
if line == "" || !strings.HasPrefix(line, "data:") {
|
||||||
@ -865,6 +883,9 @@ func (p *LuaAdaptedProvider) ChatStream(ctx context.Context, req *CompletionRequ
|
|||||||
if data == "" {
|
if data == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if debugSSE && strings.Contains(data, "tool_calls") {
|
||||||
|
log.Printf("[provider:%s] SSE raw tool_call line: %s", p.name, truncateForLog(data, 400))
|
||||||
|
}
|
||||||
if data == "[DONE]" {
|
if data == "[DONE]" {
|
||||||
if !doneSent {
|
if !doneSent {
|
||||||
if !emit(StreamChunk{Done: true}) {
|
if !emit(StreamChunk{Done: true}) {
|
||||||
@ -1208,3 +1229,11 @@ func getFloat(m map[string]interface{}, key string) float64 {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// truncateForLog 诊断日志用截断。
|
||||||
|
func truncateForLog(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
|
|||||||
@ -86,6 +86,7 @@ type Agent struct {
|
|||||||
childMu sync.Mutex
|
childMu sync.Mutex
|
||||||
childNextID int64
|
childNextID int64
|
||||||
childResults map[string]string
|
childResults map[string]string
|
||||||
|
childRunning map[string]bool // 运行中的子任务(child_result 查询时区分'运行中'与'不存在')
|
||||||
|
|
||||||
// 高优先级打断通道:interceptLoop 注入,process() 在工具循环轮次间非阻塞读取
|
// 高优先级打断通道:interceptLoop 注入,process() 在工具循环轮次间非阻塞读取
|
||||||
interceptCh chan *agentIO.InputEvent
|
interceptCh chan *agentIO.InputEvent
|
||||||
@ -237,6 +238,7 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
eventBus: cfg.EventBus,
|
eventBus: cfg.EventBus,
|
||||||
selfInputCh: make(chan selfInputMsg, 64),
|
selfInputCh: make(chan selfInputMsg, 64),
|
||||||
childResults: make(map[string]string),
|
childResults: make(map[string]string),
|
||||||
|
childRunning: make(map[string]bool),
|
||||||
interceptCh: make(chan *agentIO.InputEvent, 64),
|
interceptCh: make(chan *agentIO.InputEvent, 64),
|
||||||
pluginHealth: newPluginHealthTracker(),
|
pluginHealth: newPluginHealthTracker(),
|
||||||
thinkingEnabled: cfg.ThinkingEnabled,
|
thinkingEnabled: cfg.ThinkingEnabled,
|
||||||
|
|||||||
@ -262,6 +262,9 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
|||||||
toolsUsed = append(toolsUsed, tc.Name)
|
toolsUsed = append(toolsUsed, tc.Name)
|
||||||
pluginName := a.resolveToolPlugin(tc.Name)
|
pluginName := a.resolveToolPlugin(tc.Name)
|
||||||
log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID)
|
log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID)
|
||||||
|
if tc.RawArguments != "" {
|
||||||
|
log.Printf("[agent] tool %s raw_arguments: %s", tc.Name, truncateStr(tc.RawArguments, 300))
|
||||||
|
}
|
||||||
|
|
||||||
sdkTC := sdk.ToolCall{ID: tc.ID, Name: tc.Name, Plugin: pluginName, Arguments: tc.Arguments}
|
sdkTC := sdk.ToolCall{ID: tc.ID, Name: tc.Name, Plugin: pluginName, Arguments: tc.Arguments}
|
||||||
stageCtx.ToolCalls = []sdk.ToolCall{sdkTC}
|
stageCtx.ToolCalls = []sdk.ToolCall{sdkTC}
|
||||||
@ -402,13 +405,22 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if acc.name == "" {
|
if acc.name == "" {
|
||||||
|
log.Printf("[agent] stream tool_call idx=%d flushed with EMPTY name (args=%q) — dropped", idx, truncateStr(acc.argsRaw.String(), 120))
|
||||||
delete(accs, idx)
|
delete(accs, idx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
args, argsOK := parseToolArgsJSON(acc.argsRaw.String())
|
||||||
|
raw := strings.TrimSpace(acc.argsRaw.String())
|
||||||
|
// 空参诊断:区分「上游没发分片」(raw="")、「混拼污染」(解析失败) 与「合法空对象」({})。
|
||||||
|
if !argsOK {
|
||||||
|
log.Printf("[agent] stream tool_call %s (idx=%d) argument fragments invalid JSON: %q", acc.name, idx, truncateStr(raw, 200))
|
||||||
|
} else if raw == "" {
|
||||||
|
log.Printf("[agent] stream tool_call %s (idx=%d) received NO argument fragments", acc.name, idx)
|
||||||
|
}
|
||||||
tc := agentAPI.ToolCall{
|
tc := agentAPI.ToolCall{
|
||||||
ID: acc.id,
|
ID: acc.id,
|
||||||
Name: acc.name,
|
Name: acc.name,
|
||||||
Arguments: parseToolArgsJSON(acc.argsRaw.String()),
|
Arguments: args,
|
||||||
}
|
}
|
||||||
resp.ToolCalls = append(resp.ToolCalls, tc)
|
resp.ToolCalls = append(resp.ToolCalls, tc)
|
||||||
delete(accs, idx)
|
delete(accs, idx)
|
||||||
@ -446,9 +458,16 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 增量 tool call 分片:OpenAI 风格按 index 拼接 id/name/arguments
|
// 增量 tool call 分片:OpenAI 风格按 index 字段拼接 id/name/arguments。
|
||||||
for i, tc := range ck.ToolCalls {
|
// 注意必须用分片自带的 StreamIndex(上游 JSON "index"),不能用 Go
|
||||||
idx := i
|
// range 序号:每个 SSE chunk 通常只含一个 tool_call 元素,slice 序号
|
||||||
|
// 恒为 0,并行多工具调用(index=0,1,2...)的分片会全部污染到同一个桶,
|
||||||
|
// 导致 name 相互覆盖、args 碎片混拼解析失败(空参数工具调用)。
|
||||||
|
for _, tc := range ck.ToolCalls {
|
||||||
|
idx := tc.StreamIndex
|
||||||
|
if idx == 0 && tc.Name == "" && tc.RawArguments == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
acc := accs[idx]
|
acc := accs[idx]
|
||||||
if acc == nil {
|
if acc == nil {
|
||||||
acc = &toolCallAcc{}
|
acc = &toolCallAcc{}
|
||||||
@ -483,16 +502,17 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseToolArgsJSON 将经过完整拼接的 tool call arguments JSON 字符串解析为 map。
|
// parseToolArgsJSON 将经过完整拼接的 tool call arguments JSON 字符串解析为 map。
|
||||||
// 空字符串返回空 map。
|
// 第二个返回值 ok=false 表示分片拼接结果不是合法 JSON(分片污染/丢失),
|
||||||
func parseToolArgsJSON(s string) map[string]interface{} {
|
// 与「合法的空对象 {}」相区分。
|
||||||
if s == "" {
|
func parseToolArgsJSON(s string) (map[string]interface{}, bool) {
|
||||||
return map[string]interface{}{}
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return map[string]interface{}{}, true
|
||||||
}
|
}
|
||||||
var m map[string]interface{}
|
var m map[string]interface{}
|
||||||
if err := json.Unmarshal([]byte(s), &m); err == nil && m != nil {
|
if err := json.Unmarshal([]byte(s), &m); err == nil && m != nil {
|
||||||
return m
|
return m, true
|
||||||
}
|
}
|
||||||
return map[string]interface{}{}
|
return map[string]interface{}{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertToolCalls(tcs []agentAPI.ToolCall) []sdk.ToolCall {
|
func convertToolCalls(tcs []agentAPI.ToolCall) []sdk.ToolCall {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
@ -11,6 +12,9 @@ import (
|
|||||||
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
|
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
|
||||||
task, _ := tc.Arguments["task"].(string)
|
task, _ := tc.Arguments["task"].(string)
|
||||||
if task == "" {
|
if task == "" {
|
||||||
|
if b, _ := json.Marshal(tc.Arguments); len(b) > 2 {
|
||||||
|
log.Printf("[spawn] task empty but arguments present: %s", truncateStr(string(b), 300))
|
||||||
|
}
|
||||||
return "请提供 task 参数"
|
return "请提供 task 参数"
|
||||||
}
|
}
|
||||||
maxTurns := 0
|
maxTurns := 0
|
||||||
@ -36,6 +40,9 @@ func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
|
|||||||
parentChannel = "cli"
|
parentChannel = "cli"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.childMu.Lock()
|
||||||
|
a.childRunning[taskID] = true
|
||||||
|
a.childMu.Unlock()
|
||||||
go a.runChildTask(taskID, task, parentChannel, maxTurns)
|
go a.runChildTask(taskID, task, parentChannel, maxTurns)
|
||||||
|
|
||||||
return fmt.Sprintf("子任务已启动(ID: %s,最多 %d 轮),完成后会自动通知你,届时请使用 child_result 工具查看输出", taskID, maxTurns)
|
return fmt.Sprintf("子任务已启动(ID: %s,最多 %d 轮),完成后会自动通知你,届时请使用 child_result 工具查看输出", taskID, maxTurns)
|
||||||
@ -120,6 +127,7 @@ func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns
|
|||||||
|
|
||||||
a.childMu.Lock()
|
a.childMu.Lock()
|
||||||
a.childResults[taskID] = finalResult
|
a.childResults[taskID] = finalResult
|
||||||
|
delete(a.childRunning, taskID)
|
||||||
a.childMu.Unlock()
|
a.childMu.Unlock()
|
||||||
|
|
||||||
log.Printf("[child] %s done: %s", taskID, truncateStr(finalResult, 100))
|
log.Printf("[child] %s done: %s", taskID, truncateStr(finalResult, 100))
|
||||||
@ -139,15 +147,18 @@ func (a *Agent) executeChildResultTool(tc agentAPI.ToolCall) string {
|
|||||||
|
|
||||||
a.childMu.Lock()
|
a.childMu.Lock()
|
||||||
result, ok := a.childResults[taskID]
|
result, ok := a.childResults[taskID]
|
||||||
if !ok {
|
if ok {
|
||||||
a.childMu.Unlock()
|
|
||||||
return fmt.Sprintf("子任务 %s 不存在或已过期", taskID)
|
|
||||||
}
|
|
||||||
delete(a.childResults, taskID)
|
delete(a.childResults, taskID)
|
||||||
a.childMu.Unlock()
|
a.childMu.Unlock()
|
||||||
|
|
||||||
return fmt.Sprintf("【子任务 %s 结果】\n%s", taskID, result)
|
return fmt.Sprintf("【子任务 %s 结果】\n%s", taskID, result)
|
||||||
}
|
}
|
||||||
|
if a.childRunning[taskID] {
|
||||||
|
a.childMu.Unlock()
|
||||||
|
return fmt.Sprintf("子任务 %s 仍在运行中,尚未完成。请等待完成通知后再查询。", taskID)
|
||||||
|
}
|
||||||
|
a.childMu.Unlock()
|
||||||
|
return fmt.Sprintf("子任务 %s 不存在或已过期", taskID)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string {
|
func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string {
|
||||||
if a.providerManager == nil {
|
if a.providerManager == nil {
|
||||||
|
|||||||
87
internal/agent/core/stream_index_test.go
Normal file
87
internal/agent/core/stream_index_test.go
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 回归:并行多工具调用的流式分片必须按上游 index 字段分桶累积,
|
||||||
|
// 不能用 Go range 序号(每 chunk 单元素时恒为 0,导致全部污染到同一桶)。
|
||||||
|
func TestAccumulateStreamParallelToolCallsByIndex(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
mk := func(idx int, id, name, raw string) agentAPI.StreamChunk {
|
||||||
|
return agentAPI.StreamChunk{
|
||||||
|
ToolCalls: []agentAPI.ToolCall{{
|
||||||
|
ID: id, Type: "function", Name: name, RawArguments: raw, StreamIndex: idx,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ch := make(chan agentAPI.StreamChunk, 32)
|
||||||
|
chunks := []agentAPI.StreamChunk{
|
||||||
|
{Content: ""},
|
||||||
|
// tool_call 0: spawn_child 参数较长,分多片
|
||||||
|
mk(0, "call_a", "spawn_child", "{\"task\":"),
|
||||||
|
mk(0, "", "", "\"调查大模型排名\"}"),
|
||||||
|
// tool_call 1: browser_render
|
||||||
|
mk(1, "call_b", "browser_render", "{\"url\":"),
|
||||||
|
mk(1, "", "", "\"https://example.com\"}"),
|
||||||
|
// tool_call 2: cmd_run
|
||||||
|
mk(2, "call_c", "cmd_run", "{\"command\":\"uname -a\"}"),
|
||||||
|
// tool_call 3: skill_list
|
||||||
|
mk(3, "call_d", "skill_list", "{}"),
|
||||||
|
{Done: true, FinishReason: "tool_calls"},
|
||||||
|
}
|
||||||
|
for _, ck := range chunks {
|
||||||
|
ch <- ck
|
||||||
|
}
|
||||||
|
close(ch)
|
||||||
|
|
||||||
|
resp, err := accumulateStream(ctx, ch, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("accumulateStream: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.ToolCalls) != 4 {
|
||||||
|
t.Fatalf("expected 4 tool calls, got %d: %+v", len(resp.ToolCalls), resp.ToolCalls)
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"spawn_child": `{"task":"调查大模型排名"}`,
|
||||||
|
"browser_render": `{"url":"https://example.com"}`,
|
||||||
|
"cmd_run": `{"command":"uname -a"}`,
|
||||||
|
"skill_list": `{}`,
|
||||||
|
}
|
||||||
|
for _, tc := range resp.ToolCalls {
|
||||||
|
raw, _ := json.Marshal(tc.Arguments)
|
||||||
|
got := string(raw)
|
||||||
|
exp, ok := want[tc.Name]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("unexpected tool %q args=%s", tc.Name, got)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(want, tc.Name)
|
||||||
|
if tc.Name == "skill_list" {
|
||||||
|
// 无参工具的合法空对象 {},只需确认没被污染成乱码
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(tc.Arguments) == 0 {
|
||||||
|
t.Errorf("tool %q has EMPTY arguments (index pollution regression)", tc.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var wantMap map[string]interface{}
|
||||||
|
json.Unmarshal([]byte(exp), &wantMap)
|
||||||
|
gotB, _ := json.Marshal(wantMap)
|
||||||
|
if got != string(gotB) {
|
||||||
|
t.Errorf("tool %q args = %s, want %s", tc.Name, got, exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(want) > 0 {
|
||||||
|
t.Errorf("missing tool calls: %v", want)
|
||||||
|
}
|
||||||
|
if resp.FinishReason != "tool_calls" {
|
||||||
|
t.Errorf("finish reason = %q, want tool_calls", resp.FinishReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user