feat(ai-core): wire function calling into the LLM pipeline

- llm: ChatStreamWithTools sends OpenAI-format tools; streamed tool_call
  deltas are reassembled by index into complete ToolCalls on the Done token
- model: LLMMessage gains ToolCallID/ToolCalls; StreamToken gains ToolCalls
- orchestrator: new ToolExecutor interface and streamWithTools loop —
  execute requested tools, feed results back, up to 3 rounds, final round
  forces a plain text answer; normal chats still stream token by token.
  Also adds Pipeline.FinishRound (assistant reply recording + post-round
  hook), wired to transports in the next commit.
- main: inject the plugin registry via toolExecAdapter

Includes an httptest-based test covering delta reassembly, tool
execution, and result feedback.
This commit is contained in:
Aska committed 2026-08-20 19:40:58 +08:00
1 parent 9a3c77dbd3
commit 7c02d31194
5 files changed
+384 -11

No files matched your search

+140 -4
View File
@@ -84,7 +84,7 @@ func (a *Adapter) Chat(ctx context.Context, messages []model.LLMMessage) (*model
start := time.Now()
body := map[string]any{
"model": a.cfg.Model,
"messages": messages,
"messages": wireMessages(messages),
}
resp := &model.LLMResponse{Model: a.cfg.Model}
@@ -106,14 +106,32 @@ func (a *Adapter) Chat(ctx context.Context, messages []model.LLMMessage) (*model
return resp, err
}
// ToolSpec describes a callable tool exposed to the LLM (function calling).
type ToolSpec struct {
Name string
Description string
// Parameters is a JSON Schema object describing the tool's input.
Parameters map[string]any
}
// ChatStream sends a chat completion request and returns a token channel.
func (a *Adapter) ChatStream(ctx context.Context, messages []model.LLMMessage) (<-chan model.StreamToken, error) {
return a.ChatStreamWithTools(ctx, messages, nil)
}
// ChatStreamWithTools is ChatStream with function-calling support.
// When the model requests tool calls, they are delivered on the final
// Done token (StreamToken.ToolCalls) instead of regular content tokens.
func (a *Adapter) ChatStreamWithTools(ctx context.Context, messages []model.LLMMessage, tools []ToolSpec) (<-chan model.StreamToken, error) {
start := time.Now()
body := map[string]any{
"model": a.cfg.Model,
"messages": messages,
"messages": wireMessages(messages),
"stream": true,
}
if len(tools) > 0 {
body["tools"] = wireTools(tools)
}
ch := make(chan model.StreamToken, 64)
go func() {
@@ -203,6 +221,7 @@ func (a *Adapter) doStreamRequest(ctx context.Context, method, path string, body
}
scanner := bufio.NewScanner(resp.Body)
toolAcc := newToolCallAccumulator()
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data: ") {
@@ -210,7 +229,7 @@ func (a *Adapter) doStreamRequest(ctx context.Context, method, path string, body
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
ch <- model.StreamToken{Done: true}
ch <- model.StreamToken{Done: true, ToolCalls: toolAcc.calls()}
return nil
}
@@ -225,9 +244,10 @@ func (a *Adapter) doStreamRequest(ctx context.Context, method, path string, body
if content, ok := delta["content"].(string); ok {
ch <- model.StreamToken{Content: content}
}
toolAcc.feed(delta)
}
if reason, ok := choice["finish_reason"].(string); ok && reason != "" {
ch <- model.StreamToken{FinishReason: reason, Done: true}
ch <- model.StreamToken{FinishReason: reason, Done: true, ToolCalls: toolAcc.calls()}
return nil
}
}
@@ -242,6 +262,122 @@ func min(a, b int) int {
return b
}
// wireMessages converts LLMMessages to the OpenAI wire format,
// expanding ToolCalls into the nested function-call structure.
func wireMessages(messages []model.LLMMessage) []map[string]any {
out := make([]map[string]any, len(messages))
for i, m := range messages {
wm := map[string]any{"role": m.Role, "content": m.Content}
if m.Name != "" {
wm["name"] = m.Name
}
if m.ToolCallID != "" {
wm["tool_call_id"] = m.ToolCallID
}
if len(m.ToolCalls) > 0 {
tcs := make([]map[string]any, len(m.ToolCalls))
for j, tc := range m.ToolCalls {
args, _ := json.Marshal(tc.Arguments)
tcs[j] = map[string]any{
"id": tc.ID,
"type": "function",
"function": map[string]any{
"name": tc.Name,
"arguments": string(args),
},
}
}
wm["tool_calls"] = tcs
}
out[i] = wm
}
return out
}
// wireTools converts ToolSpecs to the OpenAI tools array format.
func wireTools(tools []ToolSpec) []map[string]any {
out := make([]map[string]any, len(tools))
for i, t := range tools {
params := t.Parameters
if params == nil {
params = map[string]any{"type": "object", "properties": map[string]any{}}
}
out[i] = map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": params,
},
}
}
return out
}
// toolCallAccumulator reassembles streamed tool_call deltas (indexed fragments
// with partial id/name/arguments) into complete ToolCalls.
type toolCallAccumulator struct {
order []int
byIdx map[int]*model.ToolCall
rawArg map[int]*strings.Builder
}
func newToolCallAccumulator() *toolCallAccumulator {
return &toolCallAccumulator{byIdx: map[int]*model.ToolCall{}, rawArg: map[int]*strings.Builder{}}
}
// feed consumes one streaming delta and merges any tool_calls fragments.
func (a *toolCallAccumulator) feed(delta map[string]any) {
tcs, ok := delta["tool_calls"].([]any)
if !ok {
return
}
for _, item := range tcs {
tcMap, ok := item.(map[string]any)
if !ok {
continue
}
idx := 0
if f, ok := tcMap["index"].(float64); ok {
idx = int(f)
}
tc, exists := a.byIdx[idx]
if !exists {
tc = &model.ToolCall{}
a.byIdx[idx] = tc
a.rawArg[idx] = &strings.Builder{}
a.order = append(a.order, idx)
}
if id, ok := tcMap["id"].(string); ok && id != "" {
tc.ID = id
}
if fn, ok := tcMap["function"].(map[string]any); ok {
if name, ok := fn["name"].(string); ok && name != "" {
tc.Name = name
}
if args, ok := fn["arguments"].(string); ok {
a.rawArg[idx].WriteString(args)
}
}
}
}
// calls returns the accumulated tool calls in index order, or nil if none.
func (a *toolCallAccumulator) calls() []model.ToolCall {
if len(a.order) == 0 {
return nil
}
calls := make([]model.ToolCall, 0, len(a.order))
for _, idx := range a.order {
tc := a.byIdx[idx]
if raw := a.rawArg[idx].String(); raw != "" {
_ = json.Unmarshal([]byte(raw), &tc.Arguments)
}
calls = append(calls, *tc)
}
return calls
}
// ModelSelector routes requests to different models based on purpose.
type ModelSelector struct {
adapters map[model.LLMPurpose]*Adapter