feat: 修复审计落差 #1/#2/#3/#6 + 管理面板移动端适配
#1 工具布尔标记生效(agent-106, 安全红线): loop toolSchemas/runToolCalls 加 allow-set 安全门 restrictSchemas/applyToolSecurityGate——群聊/陌生人只暴露允许工具(默认关), 被拒not_allowed软提示+AuditEvent, owner全工具不变; 身份靠 ActorEnv(非提示词) #2 checkpoint 恢复(agent-109): run.go LoadLatest + resumableStopReason门控(只恢复in-flight工具循环,不出答案不续跑), 幂等不重放副作用, 新会话不误恢复; resume_test 4用例 #3 tool_result(agent-108, 协议层): Anthropic 原生tool_result块(tool_use_id/is_error)非user纯文本 + OpenAI tool消息补tool_call_id; Content加ToolCallID/IsError等; (loop接线层2处留待,见备注) #6 capacity_awaken推送(agent-107): onCapacity从只Debug改推WS hub(BroadcastCapacityAwaken, sessionId定向+payload对齐前端AwakenPayload) 管理面板移动端(agent-110): AdminApp 汉堡菜单+抽屉侧栏+标题truncate防换行, RBAC守卫不受影响 全仓 build/vet/test绿 + 前端build绿; 前端无涉差异已对齐 备注: #3 loop接线层2处(assistant工具轮append历史 + Message.ToolCallID/IsError)留给后续; #4代审/#5子代理触发/#7拆文件后置(主人说后三不急)
This commit is contained in:
1 parent
aedae2190b
commit
7e39153a69
24 files changed
+1286
-56
No files matched your search
@@ -304,7 +304,7 @@ func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error)
|
||||
}
|
||||
continue
|
||||
}
|
||||
blocks, err := encodeAnthropicContent(m.Content)
|
||||
blocks, err := encodeAnthropicMessage(m)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
@@ -316,11 +316,13 @@ func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error)
|
||||
return sys.String(), out, nil
|
||||
}
|
||||
|
||||
// messageText concatenates the text parts of a message (used for system prompts).
|
||||
// messageText concatenates the text-bearing parts of a message (system prompts
|
||||
// and tool-result bodies). Tool-result parts carry their body in Text, so a
|
||||
// RoleTool turn's content serializes correctly for OpenAI's tool message.
|
||||
func messageText(m Message) string {
|
||||
var b strings.Builder
|
||||
for _, part := range m.Content {
|
||||
if part.Type == ContentTypeText {
|
||||
if part.Type == ContentTypeText || part.Type == ContentTypeToolResult {
|
||||
b.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
@@ -340,8 +342,43 @@ func anthropicRole(r Role) string {
|
||||
}
|
||||
}
|
||||
|
||||
// encodeAnthropicMessage turns a normalized message into Anthropic content
|
||||
// blocks. A RoleTool turn is ALWAYS rendered as native tool_result blocks (never
|
||||
// a user plain-text turn), because Anthropic requires a tool result to arrive as
|
||||
// a tool_result block carrying the tool_use_id and the is_error flag.
|
||||
func encodeAnthropicMessage(m Message) ([]any, error) {
|
||||
if m.Role == RoleTool {
|
||||
return encodeAnthropicToolResult(m)
|
||||
}
|
||||
return encodeAnthropicContent(m.Content)
|
||||
}
|
||||
|
||||
// encodeAnthropicToolResult renders a RoleTool turn as tool_result blocks. Each
|
||||
// ContentTypeToolResult part becomes one block; legacy text parts are wrapped as
|
||||
// tool_result blocks too so the turn never degrades into a plain user message.
|
||||
// The tool_use_id falls back to the message-level ToolCallID when a part does not
|
||||
// carry its own.
|
||||
func encodeAnthropicToolResult(m Message) ([]any, error) {
|
||||
blocks := make([]any, 0, len(m.Content))
|
||||
for _, p := range m.Content {
|
||||
switch p.Type {
|
||||
case ContentTypeToolResult, ContentTypeText:
|
||||
id := p.ToolCallID
|
||||
if id == "" {
|
||||
id = m.ToolCallID
|
||||
}
|
||||
blocks = append(blocks, AnthropicToolResult(id, p.Text, p.IsError))
|
||||
}
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
blocks = append(blocks, AnthropicToolResult(m.ToolCallID, "", false))
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
// encodeAnthropicContent turns normalized content parts into Anthropic content
|
||||
// blocks.
|
||||
// blocks. Text / image / tool_use (assistant) parts are handled here; tool_result
|
||||
// parts are rendered at the message level by encodeAnthropicToolResult.
|
||||
func encodeAnthropicContent(parts []Content) ([]any, error) {
|
||||
blocks := make([]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
@@ -361,6 +398,19 @@ func encodeAnthropicContent(parts []Content) ([]any, error) {
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
case ContentTypeToolCall:
|
||||
args := p.ToolArgs
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": p.ToolCallID,
|
||||
"name": p.ToolCallName,
|
||||
"input": args,
|
||||
})
|
||||
case ContentTypeToolResult:
|
||||
blocks = append(blocks, AnthropicToolResult(p.ToolCallID, p.Text, p.IsError))
|
||||
default:
|
||||
return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type)
|
||||
}
|
||||
@@ -421,7 +471,7 @@ func responseFromAnthropicWire(wire *anthropicResponse) Response {
|
||||
case "text":
|
||||
b.WriteString(blk.Text)
|
||||
case "tool_use":
|
||||
toolCalls = append(toolCalls, ToolCall{Name: blk.Name, Args: blk.Input})
|
||||
toolCalls = append(toolCalls, ToolCall{Name: blk.Name, ID: blk.ID, Args: blk.Input})
|
||||
}
|
||||
}
|
||||
out.Content = b.String()
|
||||
|
||||
@@ -232,6 +232,6 @@ func finalizeAnthropicStream(agg *Response, blocks map[int]*streamBlock, order [
|
||||
args = map[string]any{}
|
||||
}
|
||||
}
|
||||
agg.ToolCalls = append(agg.ToolCalls, ToolCall{Name: b.name, Args: args})
|
||||
agg.ToolCalls = append(agg.ToolCalls, ToolCall{Name: b.name, ID: b.id, Args: args})
|
||||
}
|
||||
}
|
||||
@@ -509,3 +509,103 @@ func TestAnthropicChat_ToolResult(t *testing.T) {
|
||||
t.Errorf("is_error = %v, want true", blk["is_error"])
|
||||
}
|
||||
}
|
||||
|
||||
// anthropicBlockContent asserts a message renders a single Anthropic content block
|
||||
// and returns it as a map for inspection.
|
||||
func anthropicBlockContent(t *testing.T, msg anthropicMessage) (map[string]any, bool) {
|
||||
t.Helper()
|
||||
blocks, ok := msg.Content.([]any)
|
||||
if !ok || len(blocks) != 1 {
|
||||
t.Fatalf("content = %#v, want exactly 1 content block", msg.Content)
|
||||
}
|
||||
block, ok := blocks[0].(map[string]any)
|
||||
return block, ok
|
||||
}
|
||||
|
||||
func TestAnthropicChat_Build_ToolResultBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
msgs := []Message{
|
||||
TextMessage(RoleUser, "what's the weather in Paris?"),
|
||||
AssistantToolCallMessage([]ToolCall{{ID: "toolu_1", Name: "weather", Args: map[string]any{"city": "Paris"}}}),
|
||||
ToolResultMessage("toolu_1", `{"temp":22}`, true),
|
||||
}
|
||||
sys, out, err := buildAnthropicMessages(msgs)
|
||||
if err != nil {
|
||||
t.Fatalf("buildAnthropicMessages error: %v", err)
|
||||
}
|
||||
if sys != "" {
|
||||
t.Errorf("system = %q, want empty", sys)
|
||||
}
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("messages = %d, want 3", len(out))
|
||||
}
|
||||
|
||||
// The assistant turn must carry the native tool_use block, not plain text.
|
||||
if out[1].Role != "assistant" {
|
||||
t.Errorf("assistant role = %q, want assistant", out[1].Role)
|
||||
}
|
||||
use, ok := anthropicBlockContent(t, out[1])
|
||||
if !ok {
|
||||
t.Fatal("assistant content is not a single block")
|
||||
}
|
||||
if use["type"] != "tool_use" || use["id"] != "toolu_1" || use["name"] != "weather" {
|
||||
t.Errorf("tool_use block = %#v, want type=tool_use id=toolu_1 name=weather", use)
|
||||
}
|
||||
if inp, _ := use["input"].(map[string]any); inp["city"] != "Paris" {
|
||||
t.Errorf("tool_use input = %#v, want {city:Paris}", use["input"])
|
||||
}
|
||||
|
||||
// The tool-result turn must be a user turn with a native tool_result block
|
||||
// (the audit's core fix: never a user plain-text turn).
|
||||
if out[2].Role != "user" {
|
||||
t.Errorf("tool-result role = %q, want user", out[2].Role)
|
||||
}
|
||||
tr, ok := anthropicBlockContent(t, out[2])
|
||||
if !ok {
|
||||
t.Fatal("tool-result content is not a single block")
|
||||
}
|
||||
if tr["type"] != "tool_result" {
|
||||
t.Errorf("type = %v, want tool_result (not plain user text)", tr["type"])
|
||||
}
|
||||
if tr["tool_use_id"] != "toolu_1" {
|
||||
t.Errorf("tool_use_id = %v, want toolu_1", tr["tool_use_id"])
|
||||
}
|
||||
if tr["content"] != `{"temp":22}` {
|
||||
t.Errorf("content = %v, want the raw result", tr["content"])
|
||||
}
|
||||
if tr["is_error"] != true {
|
||||
t.Errorf("is_error = %v, want true", tr["is_error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicChat_Build_ToolResultLegacyText(t *testing.T) {
|
||||
t.Parallel()
|
||||
// The loop's current form: a RoleTool message carrying a plain text part and
|
||||
// Message.ToolCallID. The protocol layer must still render a native tool_result
|
||||
// block (not a user plain-text turn).
|
||||
m := Message{Role: RoleTool, ToolCallID: "toolu_9",
|
||||
Content: []Content{{Type: ContentTypeText, Text: "tool weather result: 22"}}}
|
||||
_, out, err := buildAnthropicMessages([]Message{m})
|
||||
if err != nil {
|
||||
t.Fatalf("buildAnthropicMessages error: %v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("messages = %d, want 1", len(out))
|
||||
}
|
||||
if out[0].Role != "user" {
|
||||
t.Errorf("role = %q, want user", out[0].Role)
|
||||
}
|
||||
tr, ok := anthropicBlockContent(t, out[0])
|
||||
if !ok {
|
||||
t.Fatal("content is not a single block")
|
||||
}
|
||||
if tr["type"] != "tool_result" {
|
||||
t.Errorf("type = %v, want tool_result (not user text)", tr["type"])
|
||||
}
|
||||
if tr["tool_use_id"] != "toolu_9" {
|
||||
t.Errorf("tool_use_id = %v, want toolu_9", tr["tool_use_id"])
|
||||
}
|
||||
if tr["content"] != "tool weather result: 22" {
|
||||
t.Errorf("content = %v, want the text", tr["content"])
|
||||
}
|
||||
}
|
||||
@@ -36,17 +36,36 @@ const (
|
||||
ContentTypeText ContentType = "text"
|
||||
// ContentTypeImage is an image content part.
|
||||
ContentTypeImage ContentType = "image_url"
|
||||
// ContentTypeToolCall is an assistant-originated tool invocation part
|
||||
// (Anthropic tool_use / OpenAI tool_calls), carried on a RoleAssistant turn.
|
||||
ContentTypeToolCall ContentType = "tool_call"
|
||||
// ContentTypeToolResult is a tool execution result part, carried on a RoleTool
|
||||
// turn and encoded natively (Anthropic tool_result / OpenAI tool message).
|
||||
ContentTypeToolResult ContentType = "tool_result"
|
||||
)
|
||||
|
||||
// Content is a single, possibly multimodal, part of a message.
|
||||
type Content struct {
|
||||
// Type distinguishes a text part from an image part.
|
||||
// Type distinguishes a text part from an image / tool part.
|
||||
Type ContentType
|
||||
// Text is the text payload when Type == ContentTypeText.
|
||||
// Text is the text payload when Type == ContentTypeText, or the result body
|
||||
// when Type == ContentTypeToolResult.
|
||||
Text string
|
||||
// ImageURL is the image source when Type == ContentTypeImage. It may be a
|
||||
// data: URI or an http(s):// URL.
|
||||
ImageURL string
|
||||
// ToolCallID correlates the part to a provider tool_use/tool_call id: the call
|
||||
// id for ContentTypeToolCall, or the tool_use_id being answered for
|
||||
// ContentTypeToolResult.
|
||||
ToolCallID string
|
||||
// ToolCallName is the invoked tool name when Type == ContentTypeToolCall.
|
||||
ToolCallName string
|
||||
// ToolArgs is the structured argument object when Type == ContentTypeToolCall.
|
||||
// It is never a JSON-encoded string.
|
||||
ToolArgs map[string]any
|
||||
// IsError marks a ContentTypeToolResult part as an execution failure so the
|
||||
// provider's tool_result.is_error / error flag is set natively.
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// Message is a single conversation turn, normalized across providers.
|
||||
@@ -62,8 +81,11 @@ type Message struct {
|
||||
// channel layer. Empty means the sender is unlabeled (the owner, or an
|
||||
// unknown/unresolved sender), and callers may leave it unset.
|
||||
SenderName string
|
||||
// ToolCallID and ToolCallName are reserved as interface-level placeholders
|
||||
// for the M2 tool loop; they are ignored by current adapters.
|
||||
// ToolCallID correlates a RoleTool turn to the provider tool_use/tool_call id
|
||||
// it answers, and ToolCallName is the invoked tool name carried as a
|
||||
// convenience for callers that render the result. Both are set by the tool
|
||||
// loop when it feeds a result back, and consumed by the adapters to emit
|
||||
// native tool_result / tool-message blocks.
|
||||
ToolCallID string
|
||||
ToolCallName string
|
||||
}
|
||||
@@ -84,6 +106,25 @@ func ImageMessage(role Role, text, imageURL string) Message {
|
||||
return Message{Role: role, Content: parts}
|
||||
}
|
||||
|
||||
// ToolResultMessage builds a RoleTool turn carrying the native result of one tool
|
||||
// call. toolUseID must equal the provider id of the tool_use block this result
|
||||
// answers; isError marks an execution failure; text is the result body.
|
||||
func ToolResultMessage(toolUseID, text string, isError bool) Message {
|
||||
return Message{Role: RoleTool, ToolCallID: toolUseID,
|
||||
Content: []Content{{Type: ContentTypeToolResult, Text: text, ToolCallID: toolUseID, IsError: isError}}}
|
||||
}
|
||||
|
||||
// AssistantToolCallMessage builds a RoleAssistant turn advertising the tool calls
|
||||
// the model requested, so a subsequent RoleTool turn can be correlated to them by
|
||||
// the provider (OpenAI tool_call_id / Anthropic tool_use_id).
|
||||
func AssistantToolCallMessage(calls []ToolCall) Message {
|
||||
parts := make([]Content, 0, len(calls))
|
||||
for _, c := range calls {
|
||||
parts = append(parts, Content{Type: ContentTypeToolCall, ToolCallID: c.ID, ToolCallName: c.Name, ToolArgs: c.Args})
|
||||
}
|
||||
return Message{Role: RoleAssistant, Content: parts}
|
||||
}
|
||||
|
||||
// ToolSpec is an interface-level placeholder for a tool/function schema. The
|
||||
// full tool loop (registration, argument binding, result re-feed) is M2; here
|
||||
// it only lets a request advertise tools to the provider in the wire contract.
|
||||
@@ -99,6 +140,10 @@ type ToolSpec struct {
|
||||
// (never a serialized JSON string), so a tool loop can bind it directly without
|
||||
// re-parsing.
|
||||
type ToolCall struct {
|
||||
// ID is the provider tool_use/tool_call id, so a tool loop can correlate the
|
||||
// resulting RoleTool message to the exact call (Anthropic tool_use_id / OpenAI
|
||||
// tool_call_id). Empty when the provider emitted no id.
|
||||
ID string
|
||||
// Name is the tool being invoked.
|
||||
Name string
|
||||
// Args are the tool arguments, decoded from the provider's structured
|
||||
|
||||
@@ -48,10 +48,23 @@ type openAIStreamOpts struct {
|
||||
}
|
||||
|
||||
// openAIMessage is one request message in the wire body. Content is either a
|
||||
// plain string (text-only) or an array of {type,text|image_url} parts.
|
||||
// plain string (text-only) or an array of {type,text|image_url} parts. Tool
|
||||
// messages carry ToolCallID (required by OpenAI); assistant messages that
|
||||
// requested functions carry ToolCalls so the following tool messages can be
|
||||
// correlated.
|
||||
type openAIMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
// openAIToolCall is the wire form of one assistant tool_call in a request. OpenAI
|
||||
// requires each subsequent tool message to reference the id back to these calls.
|
||||
type openAIToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function openAIToolCallFn `json:"function"`
|
||||
}
|
||||
|
||||
// openAITool is the wire form of a function tool schema (M2 placeholder).
|
||||
@@ -86,8 +99,9 @@ type openAIChoice struct {
|
||||
|
||||
// openAIResponseMessage is the assistant message inside a non-streaming choice.
|
||||
type openAIResponseMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
// openAIDelta is the incremental payload inside a stream chunk. Reasoning content
|
||||
@@ -320,22 +334,75 @@ func (a *OpenAIChatAdapter) buildRequest(req Request, stream bool) ([]byte, erro
|
||||
func buildMessages(msgs []Message) ([]openAIMessage, error) {
|
||||
out := make([]openAIMessage, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
content, err := encodeContent(m.Content)
|
||||
wire, err := encodeOpenAIMessage(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, openAIMessage{Role: string(m.Role), Content: content})
|
||||
out = append(out, wire)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// encodeOpenAIMessage renders one normalized message in OpenAI wire form. A
|
||||
// RoleTool turn becomes a tool message carrying tool_call_id (required by OpenAI,
|
||||
// and the audit's gap); an assistant turn that advertised tool calls carries the
|
||||
// corresponding tool_calls array so the provider can correlate the tool messages.
|
||||
func encodeOpenAIMessage(m Message) (openAIMessage, error) {
|
||||
if m.Role == RoleTool {
|
||||
return openAIMessage{
|
||||
Role: string(RoleTool),
|
||||
ToolCallID: toolMessageCallID(m),
|
||||
Content: messageText(m),
|
||||
}, nil
|
||||
}
|
||||
content, err := encodeContent(m.Content)
|
||||
if err != nil {
|
||||
return openAIMessage{}, err
|
||||
}
|
||||
return openAIMessage{Role: string(m.Role), Content: content, ToolCalls: encodeToolCalls(m.Content)}, nil
|
||||
}
|
||||
|
||||
// toolMessageCallID resolves the tool_call_id for a RoleTool turn: the
|
||||
// message-level id, else the id carried on the first tool-result part.
|
||||
func toolMessageCallID(m Message) string {
|
||||
if m.ToolCallID != "" {
|
||||
return m.ToolCallID
|
||||
}
|
||||
for _, p := range m.Content {
|
||||
if p.ToolCallID != "" {
|
||||
return p.ToolCallID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// encodeToolCalls builds the assistant tool_calls array from a turn's tool-call
|
||||
// parts. Arguments are serialized to JSON because OpenAI expects a string.
|
||||
func encodeToolCalls(parts []Content) []openAIToolCall {
|
||||
calls := make([]openAIToolCall, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p.Type != ContentTypeToolCall {
|
||||
continue
|
||||
}
|
||||
arguments := "{}"
|
||||
if len(p.ToolArgs) > 0 {
|
||||
if b, err := json.Marshal(p.ToolArgs); err == nil {
|
||||
arguments = string(b)
|
||||
}
|
||||
}
|
||||
calls = append(calls, openAIToolCall{
|
||||
ID: p.ToolCallID,
|
||||
Type: "function",
|
||||
Function: openAIToolCallFn{Name: p.ToolCallName, Arguments: arguments},
|
||||
})
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
func encodeContent(parts []Content) (any, error) {
|
||||
if len(parts) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
if len(parts) == 1 && parts[0].Type == ContentTypeText {
|
||||
return parts[0].Text, nil
|
||||
}
|
||||
arr := make([]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
switch p.Type {
|
||||
@@ -346,10 +413,23 @@ func encodeContent(parts []Content) (any, error) {
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{"url": p.ImageURL},
|
||||
})
|
||||
case ContentTypeToolCall, ContentTypeToolResult:
|
||||
// Tool-call and tool-result parts are encoded at the message level
|
||||
// (tool_calls array / tool_call_id), never as text-image content.
|
||||
continue
|
||||
default:
|
||||
return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type)
|
||||
}
|
||||
}
|
||||
if len(arr) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
if len(arr) == 1 {
|
||||
if blk, ok := arr[0].(map[string]any); ok && blk["type"] == "image_url" {
|
||||
return arr, nil
|
||||
}
|
||||
return arr[0].(map[string]any)["text"], nil
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
@@ -408,6 +488,13 @@ func responseFromWire(wire *openAIChatResponse) Response {
|
||||
text := wire.Choices[0].Message.Content
|
||||
out.Content = text
|
||||
out.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: text}}}
|
||||
for _, tc := range wire.Choices[0].Message.ToolCalls {
|
||||
args := map[string]any{}
|
||||
if tc.Function.Arguments != "" {
|
||||
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
||||
}
|
||||
out.ToolCalls = append(out.ToolCalls, ToolCall{Name: tc.Function.Name, ID: tc.ID, Args: args})
|
||||
}
|
||||
}
|
||||
if u := wire.Usage; u != nil {
|
||||
out.PromptTokens = u.PromptTokens
|
||||
@@ -553,7 +640,7 @@ func scanSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error) {
|
||||
args = map[string]any{}
|
||||
}
|
||||
}
|
||||
agg.ToolCalls = append(agg.ToolCalls, ToolCall{Name: acc.name, Args: args})
|
||||
agg.ToolCalls = append(agg.ToolCalls, ToolCall{Name: acc.name, ID: acc.id, Args: args})
|
||||
}
|
||||
agg.Content = text.String()
|
||||
agg.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: agg.Content}}}
|
||||
|
||||
@@ -352,3 +352,80 @@ func TestOpenAIChat_ChatStream_ToolCall(t *testing.T) {
|
||||
t.Errorf("events = %+v, want %+v", events, wantEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIChat_Chat_ToolRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
ch := make(chan capturedRequest, 1)
|
||||
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch)
|
||||
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-test"})
|
||||
|
||||
req := Request{Messages: []Message{
|
||||
TextMessage(RoleUser, "what's the weather in Paris?"),
|
||||
AssistantToolCallMessage([]ToolCall{{ID: "call_1", Name: "weather", Args: map[string]any{"city": "Paris"}}}),
|
||||
ToolResultMessage("call_1", `{"temp":22}`, true),
|
||||
}, Model: "gpt-test"}
|
||||
if _, err := ad.Chat(context.Background(), req, Options{}); err != nil {
|
||||
t.Fatalf("Chat error: %v", err)
|
||||
}
|
||||
|
||||
got := <-ch
|
||||
msgs, _ := got.body["messages"].([]any)
|
||||
if len(msgs) != 3 {
|
||||
t.Fatalf("body.messages = %v, want 3 messages", got.body["messages"])
|
||||
}
|
||||
|
||||
// The assistant turn must carry tool_calls (so the tool message can be
|
||||
// correlated by the provider) — this was previously dropped entirely.
|
||||
assistant, _ := msgs[1].(map[string]any)
|
||||
if assistant["role"] != "assistant" {
|
||||
t.Errorf("assistant role = %v, want assistant", assistant["role"])
|
||||
}
|
||||
tcs, _ := assistant["tool_calls"].([]any)
|
||||
if len(tcs) != 1 {
|
||||
t.Fatalf("assistant tool_calls = %v, want 1 entry", assistant["tool_calls"])
|
||||
}
|
||||
tc := tcs[0].(map[string]any)
|
||||
if tc["id"] != "call_1" || tc["type"] != "function" {
|
||||
t.Errorf("tool_call = %v, want id=call_1 type=function", tc)
|
||||
}
|
||||
fn, _ := tc["function"].(map[string]any)
|
||||
if fn["name"] != "weather" || fn["arguments"] != `{"city":"Paris"}` {
|
||||
t.Errorf("function = %#v, want name=weather arguments={\"city\":\"Paris\"}", fn)
|
||||
}
|
||||
|
||||
// The tool message must carry tool_call_id (the audit's gap) and the result.
|
||||
tool, _ := msgs[2].(map[string]any)
|
||||
if tool["role"] != "tool" {
|
||||
t.Errorf("tool role = %v, want tool", tool["role"])
|
||||
}
|
||||
if tool["tool_call_id"] != "call_1" {
|
||||
t.Errorf("tool_call_id = %v, want call_1", tool["tool_call_id"])
|
||||
}
|
||||
if tool["content"] != `{"temp":22}` {
|
||||
t.Errorf("tool content = %v, want the raw result", tool["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIChat_Build_ToolMessageLegacy(t *testing.T) {
|
||||
t.Parallel()
|
||||
// The loop's current form: a RoleTool message carrying a plain text part and
|
||||
// Message.ToolCallID. buildMessages must emit a tool message with tool_call_id.
|
||||
m := Message{Role: RoleTool, ToolCallID: "call_9",
|
||||
Content: []Content{{Type: ContentTypeText, Text: "tool weather result: 22"}}}
|
||||
msgs, err := buildMessages([]Message{m})
|
||||
if err != nil {
|
||||
t.Fatalf("buildMessages error: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("messages = %d, want 1", len(msgs))
|
||||
}
|
||||
if msgs[0].Role != "tool" {
|
||||
t.Errorf("role = %q, want tool", msgs[0].Role)
|
||||
}
|
||||
if msgs[0].ToolCallID != "call_9" {
|
||||
t.Errorf("tool_call_id = %q, want call_9", msgs[0].ToolCallID)
|
||||
}
|
||||
if msgs[0].Content != "tool weather result: 22" {
|
||||
t.Errorf("content = %v, want the text", msgs[0].Content)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user