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

+17
View File
@@ -193,6 +193,7 @@ func main() {
memExtractor := memory.NewExtractor(memSvc, &memory.LLMAdapterBridge{Adapter: memAdapter}) memExtractor := memory.NewExtractor(memSvc, &memory.LLMAdapterBridge{Adapter: memAdapter})
orch.SetMemoryExtractor(memExtractor) orch.SetMemoryExtractor(memExtractor)
} }
orch.SetToolExecutor(&toolExecAdapter{reg: reg})
logger.Infof("[orchestrator] pipeline ready") logger.Infof("[orchestrator] pipeline ready")
// === 11.5 Platform Bridge === // === 11.5 Platform Bridge ===
@@ -330,6 +331,22 @@ func (a *perfAdapter) History() []map[string]any {
return result return result
} }
// toolExecAdapter adapts the plugin registry to orchestrator.ToolExecutor.
type toolExecAdapter struct{ reg *plugin.Registry }
func (t *toolExecAdapter) Tools() []llm.ToolSpec {
defs := t.reg.GetAllToolDefinitions()
specs := make([]llm.ToolSpec, len(defs))
for i, d := range defs {
specs[i] = llm.ToolSpec{Name: d.Name, Description: d.Description, Parameters: d.Parameters}
}
return specs
}
func (t *toolExecAdapter) Execute(ctx context.Context, name string, params map[string]any) (string, error) {
return t.reg.ExecuteTool(ctx, name, params)
}
// bridgeConfigAdapter adapts bridge.ConfigStore to api.BridgeConfigManager. // bridgeConfigAdapter adapts bridge.ConfigStore to api.BridgeConfigManager.
type bridgeConfigAdapter struct{ *bridge.ConfigStore } type bridgeConfigAdapter struct{ *bridge.ConfigStore }
func (a *bridgeConfigAdapter) GetAdapters() []map[string]any { func (a *bridgeConfigAdapter) GetAdapters() []map[string]any {
+140 -4
View File
@@ -84,7 +84,7 @@ func (a *Adapter) Chat(ctx context.Context, messages []model.LLMMessage) (*model
start := time.Now() start := time.Now()
body := map[string]any{ body := map[string]any{
"model": a.cfg.Model, "model": a.cfg.Model,
"messages": messages, "messages": wireMessages(messages),
} }
resp := &model.LLMResponse{Model: a.cfg.Model} 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 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. // 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) { 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() start := time.Now()
body := map[string]any{ body := map[string]any{
"model": a.cfg.Model, "model": a.cfg.Model,
"messages": messages, "messages": wireMessages(messages),
"stream": true, "stream": true,
} }
if len(tools) > 0 {
body["tools"] = wireTools(tools)
}
ch := make(chan model.StreamToken, 64) ch := make(chan model.StreamToken, 64)
go func() { go func() {
@@ -203,6 +221,7 @@ func (a *Adapter) doStreamRequest(ctx context.Context, method, path string, body
} }
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
toolAcc := newToolCallAccumulator()
for scanner.Scan() { for scanner.Scan() {
line := strings.TrimSpace(scanner.Text()) line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data: ") { 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: ") data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" { if data == "[DONE]" {
ch <- model.StreamToken{Done: true} ch <- model.StreamToken{Done: true, ToolCalls: toolAcc.calls()}
return nil return nil
} }
@@ -225,9 +244,10 @@ func (a *Adapter) doStreamRequest(ctx context.Context, method, path string, body
if content, ok := delta["content"].(string); ok { if content, ok := delta["content"].(string); ok {
ch <- model.StreamToken{Content: content} ch <- model.StreamToken{Content: content}
} }
toolAcc.feed(delta)
} }
if reason, ok := choice["finish_reason"].(string); ok && reason != "" { 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 return nil
} }
} }
@@ -242,6 +262,122 @@ func min(a, b int) int {
return b 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. // ModelSelector routes requests to different models based on purpose.
type ModelSelector struct { type ModelSelector struct {
adapters map[model.LLMPurpose]*Adapter adapters map[model.LLMPurpose]*Adapter
+10 -3
View File
@@ -42,6 +42,11 @@ type LLMMessage struct {
Role string `json:"role"` // "system" | "user" | "assistant" | "tool" Role string `json:"role"` // "system" | "user" | "assistant" | "tool"
Content string `json:"content"` Content string `json:"content"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
// ToolCallID links a tool result message to the call that produced it (role="tool").
ToolCallID string `json:"tool_call_id,omitempty"`
// ToolCalls carries the calls requested by the assistant (role="assistant").
// Marshaled to the OpenAI wire format by the llm package, hence "-".
ToolCalls []ToolCall `json:"-"`
} }
// LLMResponse represents a non-streaming LLM response. // LLMResponse represents a non-streaming LLM response.
@@ -54,9 +59,11 @@ type LLMResponse struct {
// StreamToken is a single token from a streaming LLM response. // StreamToken is a single token from a streaming LLM response.
type StreamToken struct { type StreamToken struct {
Content string `json:"content"` Content string `json:"content"`
FinishReason string `json:"finish_reason,omitempty"` FinishReason string `json:"finish_reason,omitempty"`
Done bool `json:"done"` Done bool `json:"done"`
// ToolCalls is set on the final Done token when the model requested tool calls.
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
} }
// ReviewSegment represents a parsed action/chat segment from LLM output. // ReviewSegment represents a parsed action/chat segment from LLM output.
@@ -20,6 +20,17 @@ type MemoryExtractor interface {
ExtractFromConversation(ctx context.Context, userID string, conversation []model.Message) ExtractFromConversation(ctx context.Context, userID string, conversation []model.Message)
} }
// ToolExecutor provides tool definitions and execution for function calling.
type ToolExecutor interface {
// Tools returns the tool specs exposed to the LLM.
Tools() []llm.ToolSpec
// Execute runs a tool by name with the given arguments.
Execute(ctx context.Context, name string, params map[string]any) (string, error)
}
// maxToolRounds caps consecutive tool-call rounds to avoid runaway loops.
const maxToolRounds = 3
// TraceHook is injected by the main app to record trace spans. // TraceHook is injected by the main app to record trace spans.
type TraceHook func(name, label, status string, durationMs int64, detail string) type TraceHook func(name, label, status string, durationMs int64, detail string)
@@ -35,6 +46,7 @@ type Pipeline struct {
subMgr *subsession.Manager subMgr *subsession.Manager
personaInjector *persona.Injector personaInjector *persona.Injector
memoryExtractor MemoryExtractor memoryExtractor MemoryExtractor
toolExecutor ToolExecutor
} }
// NewPipeline creates a new dialogue pipeline. // NewPipeline creates a new dialogue pipeline.
@@ -60,6 +72,11 @@ func (p *Pipeline) SetMemoryExtractor(e MemoryExtractor) {
p.memoryExtractor = e p.memoryExtractor = e
} }
// SetToolExecutor injects the tool execution capability (function calling).
func (p *Pipeline) SetToolExecutor(t ToolExecutor) {
p.toolExecutor = t
}
// ProcessInput processes a user message through the full pipeline. // ProcessInput processes a user message through the full pipeline.
// Returns a channel of StreamToken for the LLM response. // Returns a channel of StreamToken for the LLM response.
func (p *Pipeline) ProcessInput(ctx context.Context, sessionID, userMessage string) (<-chan model.StreamToken, *model.Intent, error) { func (p *Pipeline) ProcessInput(ctx context.Context, sessionID, userMessage string) (<-chan model.StreamToken, *model.Intent, error) {
@@ -91,11 +108,17 @@ func (p *Pipeline) ProcessInput(ctx context.Context, sessionID, userMessage stri
llmMessages := p.ctxBuilder.Build(systemPrompt, sessionID, subResults, "") llmMessages := p.ctxBuilder.Build(systemPrompt, sessionID, subResults, "")
llmMessages = append(llmMessages, model.LLMMessage{Role: "user", Content: userMessage}) llmMessages = append(llmMessages, model.LLMMessage{Role: "user", Content: userMessage})
// 6. LLM synthesis (streaming) // 6. LLM synthesis (streaming, with tool calling when available)
adapter := p.llmSelector.Select(model.PurposeChat) adapter := p.llmSelector.Select(model.PurposeChat)
tokenCh, err := adapter.ChatStream(ctx, llmMessages) var tokenCh <-chan model.StreamToken
if err != nil { if p.toolExecutor != nil && len(p.toolExecutor.Tools()) > 0 {
return nil, intent, err tokenCh = p.streamWithTools(ctx, adapter, llmMessages)
} else {
var err error
tokenCh, err = adapter.ChatStream(ctx, llmMessages)
if err != nil {
return nil, intent, err
}
} }
// 7. Store user message // 7. Store user message
@@ -104,6 +127,77 @@ func (p *Pipeline) ProcessInput(ctx context.Context, sessionID, userMessage stri
return tokenCh, intent, nil return tokenCh, intent, nil
} }
// streamWithTools runs the LLM streaming loop with function calling.
// Content tokens are forwarded as they arrive; when the model requests tool
// calls, the tools are executed and their results are fed back for another
// round (up to maxToolRounds). The final round omits tools to force a text
// answer.
func (p *Pipeline) streamWithTools(ctx context.Context, adapter *llm.Adapter, messages []model.LLMMessage) <-chan model.StreamToken {
out := make(chan model.StreamToken, 64)
go func() {
defer close(out)
msgs := messages
tools := p.toolExecutor.Tools()
for round := 0; round <= maxToolRounds; round++ {
useTools := tools
if round == maxToolRounds {
useTools = nil // Last chance: force a plain text answer
}
tokenCh, err := adapter.ChatStreamWithTools(ctx, msgs, useTools)
if err != nil {
out <- model.StreamToken{Content: fmt.Sprintf("[Error: %v]", err), Done: true}
return
}
var calls []model.ToolCall
for tok := range tokenCh {
if tok.Content != "" {
out <- model.StreamToken{Content: tok.Content}
}
if tok.Done {
calls = tok.ToolCalls
}
}
if len(calls) == 0 {
out <- model.StreamToken{Done: true}
return
}
// Record the assistant's tool-call request, then execute each call
// and append the results for the next round.
msgs = append(msgs, model.LLMMessage{Role: "assistant", ToolCalls: calls})
for _, call := range calls {
result, err := p.toolExecutor.Execute(ctx, call.Name, call.Arguments)
if err != nil {
result = "Error: " + err.Error()
}
msgs = append(msgs, model.LLMMessage{
Role: "tool",
ToolCallID: call.ID,
Name: call.Name,
Content: result,
})
}
}
out <- model.StreamToken{Done: true}
}()
return out
}
// FinishRound records the assistant reply in the context store and triggers
// async memory extraction. Transport layers (WS hub, platform bridge) call it
// after the response stream completes.
func (p *Pipeline) FinishRound(sessionID, userID, assistantReply string) {
if assistantReply != "" {
p.ctxStore.Add(sessionID, model.Message{Role: "assistant", Content: assistantReply})
}
p.PostProcess(sessionID, userID)
}
// PostProcess triggers async memory extraction after a conversation round completes. // PostProcess triggers async memory extraction after a conversation round completes.
func (p *Pipeline) PostProcess(sessionID, userID string) { func (p *Pipeline) PostProcess(sessionID, userID string) {
if p.memoryExtractor == nil { return } if p.memoryExtractor == nil { return }
@@ -0,0 +1,119 @@
package orchestrator
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
ctxpkg "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/context"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/persona"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/subsession"
)
// fakeToolExec records tool invocations and returns canned results.
type fakeToolExec struct {
executed []string
lastArgs map[string]any
}
func (f *fakeToolExec) Tools() []llm.ToolSpec {
return []llm.ToolSpec{{
Name: "iot_control",
Description: "控制智能家居设备",
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
}}
}
func (f *fakeToolExec) Execute(_ context.Context, name string, params map[string]any) (string, error) {
f.executed = append(f.executed, name)
f.lastArgs = params
return "设备已开启", nil
}
// TestToolCallLoop verifies that tool_call deltas from the stream are
// reassembled, executed, and fed back so the model can produce a final answer.
func TestToolCallLoop(t *testing.T) {
var round atomic.Int32
var secondReqHadToolMsg bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
n := round.Add(1)
w.Header().Set("Content-Type", "text/event-stream")
if n == 1 {
// Round 1: request must carry tools; respond with fragmented tool_calls.
if !strings.Contains(string(body), `"tools"`) {
t.Errorf("round 1 request missing tools field")
}
io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"iot_control\",\"arguments\":\"{\\\"id\\\":\\\"light_1\\\",\"}}]}}]}\n\n")
io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"action\\\":\\\"on\\\"}\"}}]}}]}\n\n")
io.WriteString(w, "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n")
io.WriteString(w, "data: [DONE]\n\n")
return
}
// Round 2: the tool result must be present in the conversation.
var req map[string]any
if err := json.Unmarshal(body, &req); err == nil {
for _, m := range req["messages"].([]any) {
msg := m.(map[string]any)
if msg["role"] == "tool" && msg["tool_call_id"] == "call_1" {
secondReqHadToolMsg = true
}
}
}
io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"灯已打开\"}}]}\n\n")
io.WriteString(w, "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")
io.WriteString(w, "data: [DONE]\n\n")
}))
defer server.Close()
llmCfg := llm.DefaultConfig()
llmCfg.BaseURL = server.URL
p := NewPipeline(
llm.NewModelSelector(llmCfg),
llm.NewSemanticCache(),
ctxpkg.NewStore(4000, 50),
subsession.NewManager(&subsession.GeneralProvider{}, &subsession.MemoryProvider{}),
persona.NewInjector(persona.BuiltinDefault()),
)
exec := &fakeToolExec{}
p.SetToolExecutor(exec)
tokenCh, _, err := p.ProcessInput(context.Background(), "s1", "打开客厅灯")
if err != nil {
t.Fatalf("ProcessInput: %v", err)
}
var content strings.Builder
var sawDone bool
for tok := range tokenCh {
content.WriteString(tok.Content)
if tok.Done {
sawDone = true
}
}
if !sawDone {
t.Error("stream never signaled Done")
}
if content.String() != "灯已打开" {
t.Errorf("final content = %q, want %q", content.String(), "灯已打开")
}
if len(exec.executed) != 1 || exec.executed[0] != "iot_control" {
t.Errorf("executed tools = %v, want [iot_control]", exec.executed)
}
if exec.lastArgs["id"] != "light_1" || exec.lastArgs["action"] != "on" {
t.Errorf("tool args = %v, want id=light_1 action=on", exec.lastArgs)
}
if !secondReqHadToolMsg {
t.Error("round 2 request missing tool result message")
}
}