返回 DeepSeek-Reasonix
transcript_gate.go
根目录 / internal / provider / transcript_gate.go
1 package provider
2
3 import (
4 "encoding/json"
5 "fmt"
6 "strings"
7 )
8
9 // ValidateTranscript validates a request view without changing history or
10 // inventing tool results. IDs are scoped to one assistant batch.
11 func ValidateTranscript(msgs []Message) error {
12 pending := map[string]string{}
13 for i, m := range msgs {
14 if m.Role != RoleTool && len(pending) != 0 {
15 return fmt.Errorf("transcript gate: unanswered tool calls before message %d", i)
16 }
17 for _, c := range m.ToolCalls {
18 if m.Role != RoleAssistant || strings.TrimSpace(c.ID) == "" || strings.TrimSpace(c.Name) == "" {
19 return fmt.Errorf("transcript gate: invalid tool identity at message %d", i)
20 }
21 if _, exists := pending[c.ID]; exists {
22 return fmt.Errorf("transcript gate: duplicate call ID at message %d", i)
23 }
24 var args map[string]json.RawMessage
25 if json.Unmarshal([]byte(c.Arguments), &args) != nil || args == nil {
26 return fmt.Errorf("transcript gate: tool arguments must be a JSON object at message %d", i)
27 }
28 pending[c.ID] = c.Name
29 }
30 if m.Role == RoleTool {
31 name, found := pending[m.ToolCallID]
32 if !found || (m.Name != "" && m.Name != name) {
33 return fmt.Errorf("transcript gate: orphan or mismatched result at message %d", i)
34 }
35 delete(pending, m.ToolCallID)
36 }
37 }
38 if len(pending) > 0 {
39 return fmt.Errorf("transcript gate: unanswered tool calls")
40 }
41 return nil
42 }
43
44 // ValidateModelTranscript uses the adapters' pairing-normalized view. Invalid
45 // arguments are rejected before normalizers can silently replace them.
46 func ValidateModelTranscript(msgs []Message) error {
47 view := ModelMessages(msgs)
48 for i, m := range view {
49 for _, c := range m.ToolCalls {
50 var args map[string]json.RawMessage
51 if json.Unmarshal([]byte(c.Arguments), &args) != nil || args == nil {
52 return fmt.Errorf("transcript gate: tool arguments must be a JSON object at message %d", i)
53 }
54 }
55 }
56 view = append([]Message(nil), SanitizeToolPairing(view)...)
57 // Some compatible gateways stream by index and omit IDs. Validate using
58 // request-local positional identities, retaining their existing wire path.
59 var empty []ToolCall
60 for i := range view {
61 m := &view[i]
62 if len(m.ToolCalls) > 0 {
63 empty = nil
64 m.ToolCalls = append([]ToolCall(nil), m.ToolCalls...)
65 for j := range m.ToolCalls {
66 if m.ToolCalls[j].ID == "" {
67 m.ToolCalls[j].ID = fmt.Sprintf("legacy-%d-%d", i, j)
68 empty = append(empty, m.ToolCalls[j])
69 }
70 }
71 } else if m.Role == RoleTool && m.ToolCallID == "" {
72 for j, c := range empty {
73 if c.Name == m.Name {
74 m.ToolCallID = c.ID
75 empty = append(empty[:j], empty[j+1:]...)
76 break
77 }
78 }
79 }
80 }
81 return ValidateTranscript(view)
82 }
83
84 // RepairRejectedArguments changes only the outbound copy of calls the host
85 // proved never ran. Their validation-error result remains available for the
86 // model to correct its next proposal; stored arguments remain inspectable.
87 func RepairRejectedArguments(msgs []Message) []Message {
88 out := append([]Message(nil), msgs...)
89 for i, m := range out {
90 if m.Role != RoleAssistant {
91 continue
92 }
93 for j, c := range m.ToolCalls {
94 var args map[string]json.RawMessage
95 if json.Unmarshal([]byte(c.Arguments), &args) == nil && args != nil {
96 continue
97 }
98 for k := i + 1; k < len(out) && out[k].Role == RoleTool; k++ {
99 r := out[k]
100 if r.ToolCallID == c.ID && r.Name == c.Name && (r.ToolRunState == ToolRunNotStarted || r.ToolRunState == ToolRunCancelled) {
101 out[i].ToolCalls = append([]ToolCall(nil), out[i].ToolCalls...)
102 out[i].ToolCalls[j].Arguments = "{}"
103 break
104 }
105 }
106 }
107 }
108 return out
109 }
110
110 lines GO