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:
1 parent
9a3c77dbd3
commit
7c02d31194
5 files changed
+384
-11
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user