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})
orch.SetMemoryExtractor(memExtractor)
}
orch.SetToolExecutor(&toolExecAdapter{reg: reg})
logger.Infof("[orchestrator] pipeline ready")
// === 11.5 Platform Bridge ===
@@ -330,6 +331,22 @@ func (a *perfAdapter) History() []map[string]any {
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.
type bridgeConfigAdapter struct{ *bridge.ConfigStore }
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()
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
+10 -3
View File
@@ -42,6 +42,11 @@ type LLMMessage struct {
Role string `json:"role"` // "system" | "user" | "assistant" | "tool"
Content string `json:"content"`
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.
@@ -54,9 +59,11 @@ type LLMResponse struct {
// StreamToken is a single token from a streaming LLM response.
type StreamToken struct {
Content string `json:"content"`
FinishReason string `json:"finish_reason,omitempty"`
Done bool `json:"done"`
Content string `json:"content"`
FinishReason string `json:"finish_reason,omitempty"`
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.
@@ -20,6 +20,17 @@ type MemoryExtractor interface {
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.
type TraceHook func(name, label, status string, durationMs int64, detail string)
@@ -35,6 +46,7 @@ type Pipeline struct {
subMgr *subsession.Manager
personaInjector *persona.Injector
memoryExtractor MemoryExtractor
toolExecutor ToolExecutor
}
// NewPipeline creates a new dialogue pipeline.
@@ -60,6 +72,11 @@ func (p *Pipeline) SetMemoryExtractor(e MemoryExtractor) {
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.
// 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) {
@@ -91,11 +108,17 @@ func (p *Pipeline) ProcessInput(ctx context.Context, sessionID, userMessage stri
llmMessages := p.ctxBuilder.Build(systemPrompt, sessionID, subResults, "")
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)
tokenCh, err := adapter.ChatStream(ctx, llmMessages)
if err != nil {
return nil, intent, err
var tokenCh <-chan model.StreamToken
if p.toolExecutor != nil && len(p.toolExecutor.Tools()) > 0 {
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
@@ -104,6 +127,77 @@ func (p *Pipeline) ProcessInput(ctx context.Context, sessionID, userMessage stri
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.
func (p *Pipeline) PostProcess(sessionID, userID string) {
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")
}
}