feat: 修复审计落差 #1/#2/#3/#6 + 管理面板移动端适配
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

#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:
Aska committed 2026-09-07 13:43:18 +08:00
1 parent aedae2190b
commit 7e39153a69
24 files changed
+1286 -56

No files matched your search

+55 -5
View File
@@ -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()
+1 -1
View File
@@ -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})
}
}
+100
View File
@@ -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"])
}
}
+49 -4
View File
@@ -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
+98 -11
View File
@@ -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}}}
+77
View File
@@ -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)
}
}