This commit is contained in:
2026-08-13 05:56:08 +07:00
parent 90fb97a4e7
commit 510cf6126c
3 changed files with 452 additions and 40 deletions
+104 -40
View File
@@ -1,6 +1,6 @@
module agentCore
export yiemAgent, _agentLoop, OpenAiToUserMessage
export yiemAgent, _agentLoop, OpenAiToUserMessage, _extractToolCalls
using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization,
DataFrames, Base.Threads, NATS
@@ -235,7 +235,7 @@ function _agentLoop(agent::yiemAgent)
break
else
agent.agentEventSink("_agentLoop push 1")
put!(processMessageInputCh, msg) #WORKING
put!(processMessageInputCh, msg)
agent.agentEventSink("_agentLoop push 2")
end
@@ -357,9 +357,6 @@ function _processMessage(
"""
while true
# Drain inputChannel and convert OpenAI-format messages to userMessage type
while isready(inputChannel)
agentEventSink("_processMessage 2")
@@ -385,47 +382,19 @@ function _processMessage(
# Call formatMessagesForLLM() to format for LLM
formattedMessages = formatMessagesForLLM(preparedContext, agentEventSink)
agentEventSink("_processMessage 10")
# Call llmCall() (blocking — the task waits here)
response = llmCall(formattedMessages)
agentEventSink("_processMessage 10")
""" response example
response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")]))
"""
response = llmCall(formattedMessages)
agentEventSink(string(response))
agentEventSink("_processMessage 11")
#WORKING Check if LLM used tool calls (inspect content for tool_call blocks)
hasToolCalls = false
toolCallList = agentToolCall[]
for content_block in response.content # extract response
if content_block isa Dict
if get(content_block, :type, "") == "tool_calls"
hasToolCalls = true
for tc_data in get(content_block, :tool_calls, [])
tc = agentToolCall(
type="function",
id=get(tc_data, :id, string(uuid4())),
name=get(tc_data, :function, Dict{String,Any}())[:name],
arguments=get(tc_data, :function, Dict{String,Any}())[:arguments],
)
push!(toolCallList, tc)
end
elseif get(content_block, :type, "") == "tool_call"
hasToolCalls = true
tc_data = content_block
tc = agentToolCall(
type="function",
id=get(tc_data, :id, string(uuid4())),
name=get(tc_data, :name, ""),
arguments=get(tc_data, :arguments, Dict{String,Any}()),
)
push!(toolCallList, tc)
end
end
end
# Extract tool calls from LLM response content blocks
hasToolCalls, toolCallList = _extractToolCalls(response)
agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList")
error("debug marker")
if hasToolCalls && length(toolCallList) > 0
# Build context and config for executeToolCalls
context = agentContext(
@@ -560,6 +529,101 @@ function createToolResultMessage(f::finalizedOutcome)::toolResultMessage
)
end
"""
_extractToolCalls(response) -> Tuple{Bool, Vector{agentToolCall}}
Extracts tool calls from the LLM response. Supports two response formats:
1. **Message format** (e.g. from LMStudio.jl / vLLM):
`response["message"]["tool_calls"]` — array of tool call objects with
`"type" => "function"`, `"function" => Dict("name" => ..., "arguments" => "...")`,
and `"id"`. The `"arguments"` value is a JSON string that gets parsed via `JSON.parse`.
2. **Content blocks format** (e.g. from OpenAI API):
`response.content` — array of content blocks. Blocks with `"type" => "tool_calls"`
contain a `"tool_calls"` array in format 1. Blocks with `"type" => "tool_call"`
have `"name"`, `"arguments"`, `"id"` at the top level (already-parsed args dict).
Returns `(hasToolCalls, toolCallList)` where `hasToolCalls` is `true` if any
tool calls were found, and `toolCallList` is a vector of `agentToolCall` structs.
# Arguments
- `response`: LLM response object (Dict/JSON.Object or struct with `.content` field)
# Returns
- `Tuple{Bool, Vector{agentToolCall}}`: `(hasToolCalls, toolCallList)`
"""
function _extractToolCalls(response)
hasToolCalls = false
toolCallList = agentToolCall[]
# Helper: parse args (JSON string -> Dict, or pass through)
parse_args(raw) = raw isa AbstractDict ? Dict{String,Any}(raw) :
raw isa String ? JSON.parse(raw) : Dict{String,Any}()
# Helper: build agentToolCall (positional)
make_tc(tc_data, default_id=string(uuid4())) = begin
func = get(tc_data, "function", Dict{String,Any}())
args = parse_args(get(func, "arguments", "{}"))
name = get(func, "name", "")
id_val = get(tc_data, "id", default_id)
agentToolCall("function", id_val, name, args)
end
# Format 1: response["message"]["tool_calls"] (LMStudio.jl / vLLM style)
msg = get(response, "message", nothing)
if msg !== nothing && msg isa AbstractDict
tc_array = get(msg, "tool_calls", nothing)
if tc_array !== nothing && tc_array isa Vector
for tc_data in tc_array
if tc_data isa AbstractDict
hasToolCalls = true
push!(toolCallList, make_tc(tc_data))
end
end
end
end
# Format 2: response.content blocks (OpenAI API style)
if !hasToolCalls
content = nothing
if response isa AbstractDict
content = get(response, "content", nothing)
else
try
content = getfield(response, :content)
catch
content = nothing
end
end
if content isa Vector
for content_block in content
if content_block isa AbstractDict
if get(content_block, "type", "") == "tool_calls"
hasToolCalls = true
for tc_data in get(content_block, "tool_calls", [])
if tc_data isa AbstractDict
push!(toolCallList, make_tc(tc_data))
end
end
elseif get(content_block, "type", "") == "tool_call"
hasToolCalls = true
tc = agentToolCall(
"function",
get(content_block, "id", string(uuid4())),
get(content_block, "name", ""),
get(content_block, "arguments", Dict{String,Any}()),
)
push!(toolCallList, tc)
end
end
end
end
end
return hasToolCalls, toolCallList
end
"""
shouldTerminate(finalizedCalls::Vector{finalizedOutcome}) -> Bool
+343
View File
@@ -0,0 +1,343 @@
using Test
using YiemAgent
using YiemAgent.agentCore
using YiemAgent.type
using JSON
# Import the function from the private module scope
import YiemAgent.agentCore: _extractToolCalls
@testset "_extractToolCalls" begin
# -------------------------------------------------------------- #
# Format 1: response["message"]["tool_calls"] (LMStudio.jl style) #
# -------------------------------------------------------------- #
@testset "single tool call via message format" begin
response = Dict{String,Any}(
"finish_reason" => "tool_calls",
"index" => 0,
"message" => Dict{String,Any}(
"role" => "assistant",
"content" => "",
"reasoning_content" => "Let me check the weather.",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"Bangkok, Thailand\"}",
),
"id" => "tc_001",
)
],
),
)
has_toolcalls, tc_list = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
@test tc_list[1].id == "tc_001"
@test tc_list[1].arguments["city"] == "Bangkok, Thailand"
end
@testset "multiple tool calls via message format" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"content" => "",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"Tokyo, Japan\"}",
),
"id" => "tc_001",
),
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => "{\"timezone\":\"Asia/Tokyo\"}",
),
"id" => "tc_002",
),
],
),
)
has_toolcalls, tc_list = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 2
@test tc_list[1].name == "getWeather"
@test tc_list[1].arguments["city"] == "Tokyo, Japan"
@test tc_list[2].name == "getTime"
@test tc_list[2].arguments["timezone"] == "Asia/Tokyo"
end
@testset "tool call with empty arguments string" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "listTools",
"arguments" => "{}",
),
"id" => "tc_empty",
),
],
),
)
has_toolcalls, tc_list = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "listTools"
@test tc_list[1].arguments == Dict{String,Any}()
end
@testset "tool call with missing id falls back to uuid" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => "{\"city\":\"NYC\"}",
),
),
],
),
)
has_toolcalls, tc_list = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test !isempty(tc_list[1].id)
@test tc_list[1].name == "getTime"
end
@testset "tool call with non-string arguments (pre-parsed)" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => Dict{String,Any}("city" => "London", "units" => "fahrenheit"),
),
"id" => "tc_parsed",
),
],
),
)
has_toolcalls, tc_list = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].arguments["city"] == "London"
@test tc_list[1].arguments["units"] == "fahrenheit"
end
# ----------------------------------------------------------- #
# Format 2: response.content blocks (OpenAI API style) #
# ----------------------------------------------------------- #
@testset "content blocks with tool_calls" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}("type" => "text", "text" => "Let me check."),
Dict{String,Any}(
"type" => "tool_calls",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"Paris\"}",
),
"id" => "tc_block_1",
),
],
),
],
)
has_toolcalls, tc_list = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
@test tc_list[1].arguments["city"] == "Paris"
end
@testset "content blocks with tool_call (single-call format)" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}(
"type" => "tool_call",
"id" => "tc_single",
"name" => "getTime",
"arguments" => Dict{String,Any}("timezone" => "Europe/London"),
),
],
)
has_toolcalls, tc_list = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getTime"
@test tc_list[1].id == "tc_single"
@test tc_list[1].arguments["timezone"] == "Europe/London"
end
# ----------------------------------------------------------- #
# Format 2 via struct-like object (no .content field) #
# ----------------------------------------------------------- #
@testset "no tool calls found" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}("type" => "text", "text" => "Hello world."),
],
)
has_toolcalls, tc_list = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
end
@testset "empty message" begin
response = Dict{String,Any}()
has_toolcalls, tc_list = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
end
@testset "message with empty tool_calls array" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[],
),
)
has_toolcalls, tc_list = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
end
@testset "Format 1 takes priority over Format 2" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"Format1\"}",
),
"id" => "tc_fmt1",
),
],
),
"content" => Any[
Dict{String,Any}(
"type" => "tool_call",
"id" => "tc_fmt2",
"name" => "getTime",
"arguments" => Dict{String,Any}("city" => "Format2"),
),
],
)
has_toolcalls, tc_list = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
end
# ----------------------------------------------------------- #
# edge cases #
# ----------------------------------------------------------- #
@testset "tool call with null arguments" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => nothing,
),
"id" => "tc_null",
),
],
),
)
has_toolcalls, tc_list = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getTime"
end
@testset "tool call with missing function key" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"id" => "tc_nofunc",
),
],
),
)
has_toolcalls, tc_list = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == ""
end
@testset "tool call with missing name in function block" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}("arguments" => "{}"),
"id" => "tc_noname",
),
],
),
)
has_toolcalls, tc_list = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == ""
end
@testset "message format with JSON.Object (JSON.parse result)" begin
json_str = JSON.json(Dict(
"message" => Dict(
"role" => "assistant",
"tool_calls" => [
Dict(
"type" => "function",
"function" => Dict("name" => "getWeather", "arguments" => "{\"city\":\"Test\"}"),
"id" => "tc_jsonobj",
),
],
),
))
parsed = JSON.parse(json_str)
has_toolcalls, tc_list = _extractToolCalls(parsed)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
@test tc_list[1].arguments["city"] == "Test"
end
end
+5
View File
@@ -0,0 +1,5 @@
using Test
using YiemAgent
include("toolTest.jl")
include("_extractToolCalls.jl")