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

+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}}}