From 75b2ce59788eeba6964a332a84126c4ef6701d3a Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 8 Aug 2026 12:11:53 +0700 Subject: [PATCH 1/8] update --- src/tools/README.md | 547 +++++++++++++++----------------------------- 1 file changed, 190 insertions(+), 357 deletions(-) diff --git a/src/tools/README.md b/src/tools/README.md index 7262450..0a3a8a8 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -2,43 +2,6 @@ Tools allow the agent to perform actions and fetch data. Each tool defines a **schema** (what arguments it accepts) and an **execution function** (what it does). -## Quick Start - -Add a new tool by creating a `.jl` file in `src/tools/`. The file must define a `getTool()` function that returns an `agentTool`: - -```julia -# src/tools/my_tool.jl - -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult - city = args["city"] - return agentToolResult( - [textContent("Hello from $(city)!")], - Dict{Any,Any}(), nothing, false - ) -end - -function getTool()::agentTool - return agentTool( - name = "my_tool", - label = "My Tool", - description = "Says hello to a city.", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "city" => Dict("type" => "string", "description" => "City name") - ), - "required" => ["city"] - ), - execute = executeTool, - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end -``` - -When `loadTools()` or `registerTool()` is called, the tool becomes available to the agent. - ## Tool Anatomy Each tool has 3 main parts: @@ -155,220 +118,177 @@ end Return `nothing` to pass, or an error `String` to fail. The error is fed back to the LLM so it can retry with corrected arguments. -## Tool Lifecycle — Framework Internals +## Tool Discovery and Lifecycle -This section traces the full code path from the moment the LLM returns tool calls to the final result being fed back into the conversation. All code references are to `agentCore.jl`. +The agent iterates through tools via a **discover → execute → loop** cycle. Here is the complete flow from the framework author's perspective: -### Phase 1: Detect Tool Calls in LLM Response - -After the LLM returns an `assistantMessage`, the loop at `agentCore.jl:220-244` inspects each `content` block: +### The Agent Loop ```julia -# agentCore.jl:217-244 -has_tool_calls = false +# agentCore.jl:175 - _process_message() +while true + # 1. Drain messages from inputChannel + while isready(agent.inputChannel) + raw_msg = take!(agent.inputChannel) + user_msg = OpenAiToUserMessage(raw_msg) + push!(agent._state.messages, user_msg) + end + + # 2. Format messages for LLM + ctx = agent.prepareContext(agent._state) + formatted = agent.formatMsgForLLM(ctx) + + # 3. Call LLM + response = agent.llmCall(formatted) + + # 4. Check if LLM used tool calls + if has_tool_calls(response.content) + # 5. Execute tools, feed results back to LLM, loop + else + # 6. No tool calls — return final response + break + end +end +``` + +### Step 1: Tool Discovery + +Tools are discovered from `agent._state.tools`, which is a `Vector{agentTool}` populated during agent creation: + +```julia +# Loading tools +tools = loadTools("src/tools") # returns Vector{agentTool} + +# Passing to agent +agent = yiemAgent( + systemPrompt = "...", + tools = tools, # ← tools stored in agent._state.tools + llmCall = my_llm_call, + agentEventSink = my_event_sink, +) +``` + +When the LLM response contains tool calls, the agent builds an `agentContext` with those tools: + +```julia +context = agentContext( + agent._state.systemPrompt, + agent._state.messages, + agent._state.tools, # ← tools available for discovery +) +``` + +### Step 2: Extract Tool Calls from LLM Response + +The agent inspects the `response.content` blocks for `tool_calls`: + +```julia +# agentCore.jl:217-245 tool_call_list = agentToolCall[] for content_block in response.content - if content_block isa Dict - # OpenAI-style: type == "tool_calls" with array of tool calls - if get(content_block, :type, "") == "tool_calls" - 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!(tool_call_list, tc) - end - # Alternative style: type == "tool_call" single dict per block - elseif get(content_block, :type, "") == "tool_call" - 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!(tool_call_list, tc) - end - end -end -``` - -Each content block with `type == "tool_calls"` or `type == "tool_call"` extracts an `agentToolCall` (id, name, arguments dict) and collects them into a `Vector{agentToolCall}`. - -### Phase 2: Dispatch to Sequential or Parallel Execution - -At `agentCore.jl:247`, the framework checks if any tool calls exist and decides execution mode: - -```julia -# agentCore.jl:247-265 -context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools) -config = agentLoopConfig( - agent._state.tools, - agent.beforeToolCall, - agent.afterToolCall, - agent.parallelToolExecute ? "parallel" : "sequential", -) -batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) -``` - -`executeToolCalls` (`agentCore.jl:988-1015`) checks: -- `config.toolExecution == "sequential"` → sequential mode -- Any tool has `parallelToolExecute == false` → sequential mode -- Otherwise → parallel mode - -### Phase 3: Per-Call Preparation (`prepareToolCall`) - -Each tool call goes through `prepareToolCall` (`agentCore.jl:511-547`): - -``` -1. Look up tool by name: find(t -> t.name == tc.name, context.tools) -2. If not found → immediateOutcome("Tool X not found", true) -3. Run tool.prepareArguments (if defined) → transforms raw LLM args -4. Run validateToolArguments → validateRequiredArgs (hook or default) - → if fails → throws ArgumentError → caught below -5. Run beforeToolCall hook (if defined) → can block execution - → if blocked → immediateOutcome("Tool execution was blocked", true) -6. Return preparedToolCall(tool, tc, validatedArgs) -``` - -If any step throws (validation, prepareArguments, beforeToolCall), the catch block at `agentCore.jl:545` converts it to an `immediateOutcome`: - -```julia -catch err - return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true) -end -``` - -### Phase 4: Execution (`executePreparedToolCall`) - -For each `preparedToolCall`, `executePreparedToolCall` (`agentCore.jl:589-617`) runs: - -```julia -function executePreparedToolCall(prep::preparedToolCall, signal, emit)::executedOutcome - updateEvents = promise[] - accepting = true - - try - result = prep.tool.execute( - prep.toolCall.id, prep.args, signal, - partialResult -> begin - if accepting - push!(updateEvents, emit(toolExecUpdateEvent(..., partialResult))) + if content_block isa Dict + if get(content_block, :type, "") == "tool_calls" + # OpenAI format: {"type": "tool_calls", "tool_calls": [...]} + 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())[:name], + arguments = get(tc_data, :function, Dict())[:arguments], + ) + push!(tool_call_list, tc) + end + elseif get(content_block, :type, "") == "tool_call" + # Alternative format: single tool_call block + tc = agentToolCall( + type = "function", + id = get(content_block, :id, string(uuid4())), + name = get(content_block, :name, ""), + arguments = get(content_block, :arguments, Dict()), + ) + push!(tool_call_list, tc) end - end - ) - accepting = false - wait.(updateEvents) - return executedOutcome(result, false) - catch err - accepting = false - wait.(updateEvents) - return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) - end -end -``` - -Key behaviors: -- Calls `tool.execute(id, args, signal, onPartialResult)` — your tool's `executeTool` function -- `signal` can be checked inside `executeTool` for cancellation -- `onPartialResult` is called for streaming updates, which are emitted as `toolExecutionUpdate` events -- `accepting` guard prevents emitting updates after the result is already captured -- `wait.(updateEvents)` ensures all streaming updates are delivered before returning -- Execution errors are caught and returned as `executedOutcome(isError=true)` — never thrown - -### Phase 5: Finalization (`finalizeExecutedToolCall`) - -After execution, `finalizeExecutedToolCall` (`agentCore.jl:675-706`) runs the `afterToolCall` hook: - -```julia -function finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)::finalizedOutcome - result = executed.result - isError = executed.isError - - if config.afterToolCall !== nothing - try - after = config.afterToolCall(afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal) - if after !== nothing - # Hook can mutate: content, details, usage, terminate, isError - result = merge(result, dict(...)) - isError = get(after, :isError, isError) - end - catch err - result = createErrorToolResult(sprint(showerror, err)) - isError = true end - end - - return finalizedOutcome(prep.toolCall, result, isError) end ``` -The hook can: -- Mask sensitive data from result content -- Normalize usage tracking -- Flip `terminate: true` based on business logic -- Wrap errors in friendlier messages for the LLM +### Step 3: Execute Each Tool Call -If the hook itself throws, the error is caught and converted to an error outcome. - -### Phase 6: Emit Events and Create Result Message - -Each call emits `toolExecutionEnd`: +For each tool call, the agent runs through the **prepare → execute → finalize** pipeline: ```julia -function emitToolExecutionEnd(finalized::finalizedOutcome, emit::Function) - emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, finalized.result, finalized.isError)) +# agentCore.jl:247-302 +if has_tool_calls && length(tool_call_list) > 0 + context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools) + config = agentLoopConfig(agent._state.tools, agent.beforeToolCall, agent.afterToolCall, execution_mode) + signal = nothing + emit = agent.agentEventSink + + # Execute all tool calls (sequential or parallel) + batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) + + # Save results to conversation history + for tool_result in batch.messages + push!(agent._state.messages, tool_result) + end + + # If any tool requested termination, break the loop + if batch.terminate + final_response = build_final_response(batch) + break + end + # Otherwise, loop back to step 2 (format + call LLM again) end ``` -Then creates the `toolResultMessage` for conversation history (`agentCore.jl:373-379`): +### Step 4: The Per-Call Pipeline -```julia -function createToolResultMessage(f::finalizedOutcome)::toolResultMessage - return toolResultMessage( - "toolResult", f.toolCall.id, f.toolCall.name, - f.result.content, f.result.details, f.result.usage, - get(f.result, :addedToolNames, string[]), f.isError, nowMillis() - ) -end -``` - -### Phase 7: Batch Assembly and Loop Control - -In `executeToolCallsSequential` (`agentCore.jl:795-829`) or `executeToolCallsParallel` (`agentCore.jl:888-936`), all results are collected: - -```julia -messages = toolResultMessage[] -for finalized in finalizedCalls - push!(messages, createToolResultMessage(finalized)) -end -return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) -``` - -`shouldTerminate` (`agentCore.jl:409`) returns `true` only if ALL tools in the batch set `result.terminate == true`. If `false`, the agent loop at `agentCore.jl:176-308` feeds the tool results back to the LLM for another turn. - -### Data Flow Summary +Each tool call goes through three phases: ``` -response.content (Vector{Any}) - └── phase 1: parse content blocks - └── tool_call_list :: Vector{agentToolCall} - └── phase 2: dispatch to sequential/parallel - └── phase 3: prepareToolCall - └── preparedToolCall or immediateOutcome - └── phase 4: executePreparedToolCall - └── executedOutcome - └── phase 5: finalizeExecutedToolCall - └── finalizedOutcome - └── phase 6: createToolResultMessage - └── toolResultMessage - └── phase 7: agentToolCallBatch - └── pushed to agent._state.messages - └── loop back to LLM +┌─────────────────────────────────────────────────────────────────┐ +│ PREPARE → prepareToolCall() │ +│ │ +│ 1. Find tool by name in context.tools │ +│ 2. Transform args via tool.prepareArguments (if defined) │ +│ 3. Validate via tool.validateRequiredArgs (or default) │ +│ 4. Run beforeToolCall hook (if defined) │ +│ └── on any failure → return immediateOutcome (skip execution) │ +│ └── success → return preparedToolCall │ +├─────────────────────────────────────────────────────────────────┤ +│ EXECUTE → executePreparedToolCall() │ +│ │ +│ 1. emit toolExecutionStart event │ +│ 2. call tool.execute(toolCallId, args, signal, onPartialResult)│ +│ 3. wait for all pending update events │ +│ └── on error → return executedOutcome(isError=true) │ +│ └── success → return executedOutcome(isError=false) │ +├─────────────────────────────────────────────────────────────────┤ +│ FINALIZE → finalizeExecutedToolCall() │ +│ │ +│ 1. Run afterToolCall hook (if defined) │ +│ - can mutate content, details, usage, terminate, isError │ +│ 2. emit toolExecutionEnd event │ +│ 3. createToolResultMessage → adds to conversation history │ +│ └── return finalizedOutcome │ +└─────────────────────────────────────────────────────────────────┘ ``` +### Step 5: Feed Results Back to LLM + +Tool results are added to `agent._state.messages` as `toolResultMessage` objects. On the next loop iteration, `formatMsgForLLM()` converts them to OpenAI format and the LLM receives the results: + +``` +Conversation history after tool execution: + [system] "You are a helpful assistant." + [user] "What's the weather in Tokyo?" + [assistant] (tool_calls: getWeather(city="Tokyo")) + [tool] tool_call_id="call_1", tool_name="getWeather", content="Weather in Tokyo: Sunny, 22°C" +``` + +The LLM then decides: call another tool, or return a final text answer. + ## Execution Modes ### Sequential @@ -424,124 +344,53 @@ tool = getTool() # from your tool module registerTool(tool) ``` -## Using Tools with an Agent - -Loading tools only registers them — you must pass them to the `yiemAgent` and provide an `llmCall` function. Here is the complete flow: +## Complete Lifecycle Example ```julia -using .YiemAgent -using .toolRegistry - -# 1. Load tools from the tools directory -tools = loadTools("src/tools") -# [toolRegistry] Loading tool from: src/tools/getTime.jl -# [toolRegistry] Loaded tool: getTime — Time Lookup -# [toolRegistry] Loading tool from: src/tools/getWeather.jl -# [toolRegistry] Loaded tool: getWeather — Weather Lookup - -# 2. Define your LLM call function -function my_llm_call(messages::Dict)::assistantMessage - # Call your LLM API here (OpenAI, Anthropic, local model, etc.) - # Return an assistantMessage with the response content - # If the LLM wants to call a tool, include tool_call content blocks - ... -end - -# 3. Define your event sink (optional, for logging/debugging) -function my_event_sink(event) - if event isa toolExecStartEvent - println("[EVENT] Tool start: $(event.toolName)") - elseif event isa toolExecEndEvent - status = event.isError ? "ERROR" : "OK" - println("[EVENT] Tool end: $(event.toolName) — $status") - end -end - -# 4. Create the agent with tools -agent = yiemAgent( - systemPrompt = "You are a helpful assistant that can check weather and time.", - model = my_model, - tools = tools, # pass loaded tools - llmCall = my_llm_call, # your LLM function - agentEventSink = my_event_sink, # event handler -) - -# 5. Send a message and get a response +# ─── USER SENDS MESSAGE ─────────────────────────────────────────── run_agent(agent, "What's the weather in Tokyo?") -response = take_response(agent) -# response.content contains the LLM's reply (with tool results if applicable) +# ─── LOOP ITERATION 1 ───────────────────────────────────────────── +# Agent formats messages and calls LLM +formatted = agent.formatMsgForLLM(agent.prepareContext(agent._state)) +response = agent.llmCall(formatted) +# LLM returns: {"content": [{"type": "tool_calls", "tool_calls": [{"name": "getWeather", "arguments": {"city": "Tokyo"}}]}]} + +# Agent extracts tool call, builds context +context = agentContext(systemPrompt, messages, agent._state.tools) +tool_call_list = [agentToolCall("call_1", "getWeather", Dict("city" => "Tokyo"))] + +# PREPARE: find tool, validate args +tool = find(t -> t.name == "getWeather", context.tools) # found! +validateRequiredArgs(Dict("city" => "Tokyo"), tool.inputSchema) # passes +beforeToolCall_hook(agentMsgCtx, nothing) # nil, skipped + +# EXECUTE: call tool.execute() +result = tool.execute("call_1", Dict("city" => "Tokyo"), nothing, onPartialResult) +# Returns: agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], Dict(), nothing, false) + +# FINALIZE: afterToolCall hook, emit events +finalized = finalizedOutcome(tc, result, false) +emit(toolExecEndEvent("call_1", "getWeather", result, false)) +msg = createToolResultMessage(finalized) # toolResultMessage for conversation history + +# Add result to conversation +push!(agent._state.messages, msg) +# Messages now: [user: "What's the weather?", assistant: {tool_calls: getWeather}, tool: "Sunny, 22°C"] + +# ─── LOOP ITERATION 2 ───────────────────────────────────────────── +# LLM called again with tool result included +formatted = agent.formatMsgForLLM(agent.prepareContext(agent._state)) +response = agent.llmCall(formatted) +# LLM returns: {"content": [{"type": "text", "text": "The weather in Tokyo is sunny, 22°C."}]} + +# No tool calls detected → break loop, return final response +return assistantMessage(content=[textContent("The weather in Tokyo is sunny, 22°C.")], ...) + +# ─── USER RECEIVES RESPONSE ─────────────────────────────────────── +response = take_response(agent) println(response.content) - -# 6. When done, stop the agent -stop_agent(agent) -``` - -### How It Works - -1. **User sends a message** via `run_agent(agent, "What's the weather in Tokyo?")`. The message goes into `inputChannel`. - -2. **Agent loop** (`_agent_loop`) picks it up, converts it to a `userMessage`, and adds it to `agent._state.messages`. - -3. **LLM is called** via `agent.llmCall(formatted_messages)`. The LLM sees the system prompt, conversation history, and the tool definitions in the prompt (via `formatMsgForLLM`). - -4. **If the LLM uses a tool**, it returns a response with `tool_call` content blocks. The agent: - - Extracts each tool call (name, arguments) - - Runs validation (`validateRequiredArgs` or default) - - Executes the tool (or returns an error if validation fails) - - Feeds the result back as a `toolResultMessage` in the conversation - -5. **LLM is called again** with the tool results. This repeats until the LLM returns a text response with no tool calls. - -6. **Final response** is sent to `outputChannel` — retrieve it with `take_response(agent)`. - -### Minimal Working Example - -```julia -using .YiemAgent -using .toolRegistry - -# Load tools -tools = loadTools("src/tools") - -# Mock LLM that echoes back a tool call, then a text response -call_count = 0 -function mock_llm_call(messages::Dict)::assistantMessage - global call_count += 1 - if call_count == 1 - # First call: LLM decides to use getWeather - return assistantMessage( - content=[ - Dict("type" => "tool_calls", - "tool_calls" => [Dict("id" => "call_1", "name" => "getWeather", - "arguments" => Dict("city" => "Tokyo"))]) - ], - model = "mock", - usage = llmUsage(0, 0) - ) - else - # Second call: LLM returns text (after tool result) - return assistantMessage( - content = [textContent("The weather in Tokyo is sunny, 22°C.")], - model = "mock", - usage = llmUsage(0, 0) - ) - end -end - -# Create agent -agent = yiemAgent( - systemPrompt = "You are a helpful assistant.", - tools = tools, - llmCall = mock_llm_call, - agentEventSink = e -> nothing, # no events -) - -# Run -run_agent(agent, "What's the weather in Tokyo?") -response = take_response(agent) - -stop_agent(agent) +# => "[textContent(\"The weather in Tokyo is sunny, 22°C.\")]" ``` ## Available Tools @@ -550,19 +399,3 @@ stop_agent(agent) |---|---|---| | `getWeather` | Fetch weather for a city | Default (JSON Schema required) | | `getTime` | Get current time for a timezone or city | Custom (cross-field + format) | - -## Example: Error Flow - -When the LLM calls a tool with invalid arguments: - -``` -User: "What's the weather?" - └── LLM: call getWeather() with no arguments - └── prepareToolCall → validateRequiredArgs → "Missing required arguments: city" - └── immediateOutcome → error tool result - └── LLM sees: "Missing required arguments: city" - └── LLM retries: call getWeather(city="Tokyo") - └── executeTool → "Weather in Tokyo: Sunny, 22°C" -``` - -The agent feeds the error back to the LLM as a tool result message, allowing it to self-correct. From 70296a3bf28d574571f7e0411504a5f3b6e3e472 Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 8 Aug 2026 12:21:52 +0700 Subject: [PATCH 2/8] update --- src/utils.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils.jl b/src/utils.jl index db3e51d..fb75d77 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -113,7 +113,7 @@ function prepareContext(state::agentState)::agentContext #TODO filter tools from state.tools based on user intend in user message and tool description filteredTools = state.tools - #TODO add tools to current system prompt + #TODO add filtered tools to the current system prompt / modify systemPrompt here preparedSystemPrompt = state.systemPrompt #TODO add system prompt, adjust/modify and inject additional context into messages @@ -375,7 +375,7 @@ function validateToolArguments(tool::agentTool, prepared::agentToolCall)::Dict{S end return prepared.arguments -end +end From 03e1dd7628d03b50f61fca331e24df5b9ec7d1b6 Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 8 Aug 2026 18:49:51 +0700 Subject: [PATCH 3/8] update --- src/tools/README.md | 131 ++++++++++++++++++++ src/tools/registry.jl | 51 ++++++-- src/tools/writeTool.jl | 269 +++++++++++++++++++++++++++++++++++++++++ src/type.jl | 2 +- 4 files changed, 441 insertions(+), 12 deletions(-) create mode 100644 src/tools/writeTool.jl diff --git a/src/tools/README.md b/src/tools/README.md index 0a3a8a8..9b7df7a 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -393,9 +393,140 @@ println(response.content) # => "[textContent(\"The weather in Tokyo is sunny, 22°C.\")]" ``` +## Self-Modifying Tools + +The framework includes tools that allow the agent to create new tools at runtime. + +### `writeTool` — Create New Tool Files + +The `writeTool` tool generates a new Julia tool file at `src/tools/.jl`. The agent writes the tool, then the agent (or system) restarts so `loadTools("src/tools")` picks it up. + +**Workflow:** + +1. Agent identifies a task that no existing tool can handle +2. Agent calls `writeTool` with a tool specification: + +```julia +# Agent sends this to writeTool: +Dict( + "name" => "searchWine", + "label" => "Wine Search", + "description" => "Search a wine database by name, region, or variety", + "inputSchema" => Dict( + "type" => "object", + "properties" => Dict( + "query" => Dict("type" => "string", "description" => "Search query"), + "maxResults" => Dict("type" => "integer", "default" => 10) + ), + "required" => ["query"] + ), + "executeCode" => """ + query = args["query"] + max_results = get(args, "maxResults", 10) + # Perform search logic here + result = "Found 3 wines matching: $query" + return agentToolResult([textContent(result)], Dict{Any,Any}(), nothing, false) + """, + "parallel" => false +) +``` + +3. Agent calls `listTools` to check for name collisions (built-in, auto-registered) +4. Agent calls `writeTool` with a unique name +5. Restart agent — `loadTools("src/tools")` picks up the new file +6. Agent's next LLM turn discovers and calls the new tool + +**Generated file format:** + +```julia +# Auto-generated tool: searchWine +# Generated by writeTool at 2026-08-08T14:00:00 + +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult + query = args["query"] + max_results = get(args, "maxResults", 10) + result = "Found 3 wines matching: $query" + return agentToolResult([textContent(result)], Dict{Any,Any}(), nothing, false) +end + +function getTool()::agentTool + return agentTool( + name = "searchWine", + label = "Wine Search", + description = "Search a wine database by name, region, or variety", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict( + "query" => Dict("type" => "string", "description" => "Search query"), + "maxResults" => Dict("type" => "integer", "default" => 10) + ), + "required" => ["query"] + ), + execute = executeTool, + validateRequiredArgs = nothing, + prepareArguments = nothing, + parallelToolExecute = false + ) +end +``` + +**Optional hooks:** + +| Field | Description | +|---|---| +| `validateCode` | Custom validation Julia code (runs before execute). Return `nothing` to pass, or an error `String` to fail. | +| `prepareCode` | Argument preparation code (runs before validation). Return modified args dict. | + +### `listTools` — Discover Available Tools + +Returns a list of all registered tools with names, labels, and descriptions. + +```julia +# Result from listTools: +# Available tools: +# - getWeather: Weather Lookup — Fetch current weather and forecast for a given city. +# - getTime: Time Lookup — Get current local time for a timezone or city. +# - writeTool: Create Tool — Generate new tool files... +# - listTools: List Tools — List all available tools with their names and labels... +``` + +### Complete Self-Tooling Example + +``` +User: "I need to search for wines. Do you have a tool for that?" + +# ─── LOOP: Agent realizes no wine search tool exists ───────────────── + +[Tool Call] listTools() +# Result: lists all available tools — no collision with existing tools + +[Tool Call] writeTool(name="searchWine", label="Wine Search", + description="Search a wine database by name, region, or variety", + inputSchema={...}, + executeCode="query = args[\"query\"]\nresult = \"Found wines...\"\nreturn agentToolResult([textContent(result)], ...)") + +# writeTool generates searchWine.jl + +# ─── SYSTEM RESTARTS ───────────────────────────────────────────────── + +# Agent restarts — loadTools("src/tools") picks up searchWine.jl +# searchWine is now available — agent uses it directly + +# ─── Agent calls the new tool ───────────────────────────────────────── + +[Tool Call] searchWine(query="cabernet", maxResults=5) +# Result: "Found 5 cabernet wines..." + +# ─── Final response ────────────────────────────────────────────────── + +"The search found 5 cabernet wines: ..." +``` + ## Available Tools | Tool | Description | Validation | |---|---|---| | `getWeather` | Fetch weather for a city | Default (JSON Schema required) | | `getTime` | Get current time for a timezone or city | Custom (cross-field + format) | +| `writeTool` | Create a new Julia tool module at runtime | Built-in (name + schema validation) | +| `listTools` | List all available tools with descriptions | None (no arguments) | diff --git a/src/tools/registry.jl b/src/tools/registry.jl index 5ce045f..65b1085 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -1,12 +1,51 @@ module toolRegistry -export loadTools, registerTool, getTools, listTools, clearTools +export loadTools, registerTool, getTools, clearTools using ..type # Global registry — populated at runtime by loadTools() or registerTool() const _registry = Vector{agentTool}() +# Auto-register the built-in listTools tool +function __init__() + registerTool(_listTool()) +end + +""" +List tool definition — lets the agent query available tools for collision detection +when creating new tools via writeTool. +""" +function _listTool()::agentTool + return agentTool( + name = "listTools", + label = "List Tools", + description = "List all available tools with their names, labels, and descriptions. Use this before creating a new tool to check for name collisions.", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict{String,Any}(), + "required" => Any[] + ), + execute = (toolCallId, args, signal, onPartialResult) -> begin + tools = getTools() + if isempty(tools) + result_text = "No tools registered." + else + lines = String["- $(t.name): $(t.label) — $(t.description)" for t in tools] + result_text = "Available tools:\n" * join(lines, "\n") + end + return agentToolResult( + [textContent(result_text)], + Dict{Any,Any}("count" => length(tools)), + nothing, false + ) + end, + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false + ) +end + """ Load all tool modules from a directory. @@ -120,16 +159,6 @@ function getTools()::Vector{agentTool} return deepcopy(_registry) end -""" -List all registered tool names and labels. - -# Returns -- `Vector{Tuple{String,String}}`: Pairs of (name, label) -""" -function listTools()::Vector{Tuple{String,String}} - return [(t.name, t.label) for t in _registry] -end - """ Clear all registered tools from the global registry. """ diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl new file mode 100644 index 0000000..a318232 --- /dev/null +++ b/src/tools/writeTool.jl @@ -0,0 +1,269 @@ +""" +Tool that generates new Julia tool module files. + +The agent can use this tool when it encounters a task that no existing tool +can handle. Provide the tool's name, label, description, inputSchema, and +execute logic as Julia code. The tool is written to `src/tools/.jl`. + +After calling this tool, restart the agent so the new tool is loaded by +`loadTools("src/tools")`. Then call `listTools` to verify the new tool +is available. + +# Example + +1. Agent calls writeTool with a spec for a "searchWine" tool +2. writeTool generates src/tools/searchWine.jl +3. Restart agent — loadTools() picks up the new file +4. Agent calls searchWine with args + +# Important Notes + +- The `executeCode` string is embedded literally into the generated tool. + Use `args["param_name"]` to access input parameters. +- The code string should be the function body (NOT wrapped in a function). + Lines will be indented with 4 spaces inside the execute function. +- Tool names must be valid Julia identifiers (lowercase letters, digits, underscores, + no leading digits or special characters). +""" + +""" +Validate that a tool name is a valid Julia identifier. +""" +function validateToolName(name::String)::Union{Nothing,String} + if !occursin(r"^[a-zA-Z_][a-zA-Z0-9_!]*$", name) + return "Invalid tool name: '$name'. Tool names must be valid Julia identifiers (letters, digits, underscores, starting with a letter or underscore)." + end + return nothing +end + +""" +Execute the writeTool. + +Generates a new .jl tool file and registers it with the tool registry. +""" +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult + tool_name = get(args, "name", "")::String + tool_label = get(args, "label", tool_name)::String + tool_description = get(args, "description", "")::String + tool_schema = get(args, "inputSchema", Dict{String,Any}())::Dict{String,Any} + execute_code = get(args, "executeCode", "")::String + validate_code = get(args, "validateCode", nothing)::Union{String,Nothing} + prepare_code = get(args, "prepareCode", nothing)::Union{String,Nothing} + parallel = get(args, "parallel", false)::Bool + + # Validate tool name + name_err = validateToolName(tool_name) + if name_err !== nothing + return agentToolResult( + [textContent(name_err)], + Dict{Any,Any}(), nothing, false + ) + end + + # Validate required fields + if isempty(tool_name) + return agentToolResult( + [textContent("Missing required field: 'name'")], + Dict{Any,Any}(), nothing, false + ) + end + if isempty(tool_description) + return agentToolResult( + [textContent("Missing required field: 'description'")], + Dict{Any,Any}(), nothing, false + ) + end + if isempty(execute_code) + return agentToolResult( + [textContent("Missing required field: 'executeCode'")], + Dict{Any,Any}(), nothing, false + ) + end + + onPartialResult(Dict("status" => "Generating tool: $tool_name")) + + # Build the tool file path + script_dir = dirname(@__FILE__) + tools_dir = dirname(script_dir) + filepath = joinpath(tools_dir, "$(tool_name).jl") + + # Check for naming conflicts + if isfile(filepath) + return agentToolResult( + [textContent("Tool file already exists: $filepath. Rename the tool or delete the existing file first.")], + Dict{Any,Any}(), nothing, false + ) + end + + onPartialResult(Dict("status" => "Writing file: $(basename(filepath))")) + + # Convert schema Dict to a Julia Dict literal string + schema_literal = dict_to_julia_literal(tool_schema) + + # Build optional validation function + validate_section = if validate_code !== nothing && !isempty(validate_code) + indented = indent_code(validate_code, 4) + "function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}\n$indented\n return nothing\nend\n" + else + "" + end + + # Build optional prepare function + prepare_section = if prepare_code !== nothing && !isempty(prepare_code) + indented = indent_code(prepare_code, 4) + "function prepareArguments(args::Dict{String,Any})::Dict{String,Any}\n$indented\n return args\nend\n" + else + "" + end + + # Indent user's execute code for embedding inside execute function body + indented_exec = indent_code(execute_code, 4) + + # Escape description for Julia string literal + escaped_desc = replace(tool_description, "\\" => "\\\\") + escaped_desc = replace(escaped_desc, "\"" => "\\\"") + + # Build the complete tool file content + parts = String[] + push!(parts, "# Auto-generated tool: $tool_name\n") + push!(parts, "# Generated by writeTool at $(now())\n\n") + if !isempty(validate_section) + push!(parts, validate_section) + push!(parts, "\n") + end + if !isempty(prepare_section) + push!(parts, prepare_section) + push!(parts, "\n") + end + push!(parts, "\n") + push!(parts, "# Execute function\n") + push!(parts, "function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult\n") + push!(parts, "$indented_exec\n") + push!(parts, "end\n\n") + push!(parts, "# Tool definition\n") + push!(parts, "function getTool()::agentTool\n") + push!(parts, " return agentTool(\n") + push!(parts, " name = \"$(tool_name)\",\n") + push!(parts, " label = \"$(tool_label)\",\n") + push!(parts, " description = \"$(escaped_desc)\",\n") + push!(parts, " inputSchema = $schema_literal,\n") + push!(parts, " execute = executeTool,\n") + if validate_code !== nothing && !isempty(validate_code) + push!(parts, " validateRequiredArgs = validateRequiredArgs,\n") + else + push!(parts, " validateRequiredArgs = nothing,\n") + end + if prepare_code !== nothing && !isempty(prepare_code) + push!(parts, " prepareArguments = prepareArguments,\n") + else + push!(parts, " prepareArguments = nothing,\n") + end + push!(parts, " parallelToolExecute = $parallel\n") + push!(parts, " )\n") + push!(parts, "end\n") + + tool_code = join(parts) + + # Write the file — tool is loaded on next agent restart via loadTools() + write(filepath, tool_code) + + onPartialResult(Dict("status" => "Done")) + + return agentToolResult( + [textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools() picks it up, then call listTools to verify.")], + Dict{Any,Any}( + "file" => filepath, + "name" => tool_name, + "label" => tool_label, + "description" => tool_description, + ), + nothing, false + ) +end + +""" +Indent a multi-line code string by the specified number of spaces. +""" +function indent_code(code::String, n::Int)::String + prefix = " "^n + lines = split(code, '\n') + result_lines = String[prefix * line for line in lines] + return join(result_lines, "\n") +end + +""" +Convert a Julia Dict to a valid Julia Dict{String,Any}(...) literal string. +""" +function dict_to_julia_literal(d)::String + if d isa Dict + items = String[] + for (k, v) in d + key_str = json_string(k) + val_str = value_to_julia(v) + push!(items, "$key_str => $val_str") + end + return "Dict{String,Any}(" * join(items, ", ") * ")" + else + return value_to_julia(d) + end +end + +function value_to_julia(v)::String + if v isa Dict + return dict_to_julia_literal(v) + elseif v isa Vector + items = [value_to_julia(x) for x in v] + return "[" * join(items, ", ") * "]" + elseif v isa String + escaped = replace(v, "\\" => "\\\\") + escaped = replace(escaped, "\"" => "\\\"") + return "\"$escaped\"" + elseif v isa Number + return string(v) + elseif v isa Bool + return string(v) + elseif v === nothing + return "nothing" + else + return "\"$(v)\"" + end +end + +""" +Convert any Julia value to a JSON string. +""" +function json_string(v)::String + return JSON.json(v) +end + +""" +Define and return the writeTool agentTool. +""" +function getTool()::agentTool + return agentTool( + name = "writeTool", + label = "Create Tool", + description = "Generate a new Julia tool module file at src/tools/.jl. After creation, restart the agent and call listTools to verify the new tool is loaded.", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict( + "name" => Dict("type" => "string", "description" => "Unique tool name (valid Julia identifier, no spaces or special chars)"), + "label" => Dict("type" => "string", "description" => "Human-readable tool name shown in tool descriptions"), + "description" => Dict("type" => "string", "description" => "What the tool does (shown to LLM for tool selection decisions)"), + "inputSchema" => Dict( + "type" => "object", + "description" => "JSON Schema describing tool parameters in MCP format" + ), + "executeCode" => Dict("type" => "string", "description" => "Julia code for the execute function body. Use args[\"key\"] to access parameters. Do NOT wrap in a function definition."), + "validateCode" => Dict("type" => "string", "optional" => true, "description" => "Optional custom validation Julia code (runs before execute). Use args[\"key\"] to access parameters. Return nothing to pass, or a string error message to fail."), + "prepareCode" => Dict("type" => "string", "optional" => true, "description" => "Optional argument preparation code (runs before validation). Return modified args dict."), + "parallel" => Dict("type" => "boolean", "default" => false, "description" => "Whether this tool can run in parallel with other tools") + ), + "required" => ["name", "label", "description", "inputSchema", "executeCode"] + ), + execute = executeTool, + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false + ) +end diff --git a/src/type.jl b/src/type.jl index 60ae3c5..387d49c 100644 --- a/src/type.jl +++ b/src/type.jl @@ -9,7 +9,7 @@ # Message types userMessage, assistantMessage, toolResultMessage, # Tool types - agentTool, validateRequiredArgs + agentTool, validateRequiredArgs # Context types agentContext, agentState, agentToolCall, prepareNextTurnContext, # Loop & execution types From 2aa0d1e9a402b88a7f9898fbeea94576955230ad Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 8 Aug 2026 19:44:06 +0700 Subject: [PATCH 4/8] update --- README.md | 49 +++++++++++++--------- docs/loadtools.md | 92 ------------------------------------------ src/tools/README.md | 91 +++++++++++++++++++---------------------- src/tools/writeTool.jl | 19 ++++++--- 4 files changed, 86 insertions(+), 165 deletions(-) delete mode 100644 docs/loadtools.md diff --git a/README.md b/README.md index 4702dd2..aa9f04c 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,34 @@ # YiemAgent -## TODO -- [WORKING] build prompt() -- [ ] build agent runLoop() -- [ ] build MCP server connector -- [ ] executeplan() to execute the plan -- [ ] add comprehensive tests +Julia framework for building agents with tool use. -## Changelog +## Getting Started -### Version 0.8.0 -- Converted snake_case fields to camelCase: - - `llmModel`: `base_url` → `baseUrl`, `context_window` → `contextWindow`, `max_tokens` → `maxTokens` -- Converted PascalCase type references to camelCase: - - `AgentState` → `agentState` - - `AgentTool` → `agentTool` - - `AgentMessage` → `agentMessage` - - `PendingMessageQueue` → `pendingMessageQueue` - - `ActiveRun` → `activeRun` - - `StreamFn` → `streamFn` - - `ThinkingLevel` → `thinkingLevel` - - `ToolExecutionMode` → `toolExecutionMode` +1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, ...` +2. Create a `yiemAgent` with `loadTools("src/tools")` +3. Call `run_agent(agent, "message")` then `take_response(agent)` + +## Architecture + +``` +src/ +├── YiemAgent.jl # Module entry point +├── type.jl # Core types (messages, tools, agent state) +├── utils.jl # Message formatting, validation +├── agentCore.jl # Agent loop, tool execution pipeline +├── api.jl # Public API (run_agent, take_response, etc.) +└── tools/ + ├── registry.jl # Tool registry (loadTools, registerTool, listTools) + ├── getWeather.jl # Weather lookup tool + ├── getTime.jl # Time lookup tool + ├── writeTool.jl # Create new tool files (self-modifying) + └── README.md # Tool development guide +``` + +## Tool Development + +See `src/tools/README.md` for: +- Tool anatomy (schema, execute, getTool) +- Validation hooks +- Agent loop lifecycle +- Self-modifying tools (`writeTool`) diff --git a/docs/loadtools.md b/docs/loadtools.md deleted file mode 100644 index 42f446c..0000000 --- a/docs/loadtools.md +++ /dev/null @@ -1,92 +0,0 @@ -# Dynamic Tool Loading - -Tools can be loaded dynamically from `.jl` files in the `src/tools/` directory without hardcoding filenames in the main module. - -## How It Works - -1. `src/tools/registry.jl` defines a `loadTools(dir::String)` function that scans a directory for `.jl` files -2. Each tool file must define a single function: `getTool()::agentTool` -3. `loadTools()` sorts files alphabetically, includes each one, calls `getTool()`, and registers the result -4. Loaded tools are returned as `Vector{agentTool}` for use when constructing a `yiemAgent` - -## Directory Structure - -``` -src/ -├── tools/ -│ ├── registry.jl # Tool loader (do not edit) -│ ├── getWeather.jl # Your tool -│ └── query_db.jl # Another tool -├── type.jl -├── utils.jl -├── agentCore.jl -├── api.jl -└── YiemAgent.jl -``` - -## Creating a Tool - -Each `.jl` file in `src/tools/` must define `getTool()` returning an `agentTool`: - -```julia -# src/tools/getWeather.jl -function getTool()::agentTool - return agentTool( - name = "getWeather", - label = "Weather Lookup", - description = "Fetch current weather and forecast for a given city.", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "city" => Dict("type" => "string", "description" => "City and country"), - "units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius") - ), - "required" => ["city"] - ), - execute = (toolCallId, args, signal, onPartialResult) -> begin - city = args["city"] - return agentToolResult( - [textContent("Weather in $(city): Sunny, 22C")], - Dict{Any,Any}(), nothing, false - ) - end, - prepareArguments = nothing, - parallelToolExecute = false - ) -end -``` - -No `module` wrapper needed — the registry includes each file in the current module scope so all types (`agentTool`, `textContent`, `agentToolResult`, etc.) resolve correctly. - -## Loading Tools - -```julia -using .YiemAgent -using .YiemAgent: toolRegistry - -# Load all tool files from src/tools/ -tools = YiemAgent.loadTools(joinpath(@__DIR__, "src", "tools")) - -# Create agent with loaded tools -agent = yiemAgent( - systemPrompt = "You are a helpful assistant.", - model = my_model, - tools = tools, - llmCall = my_llm_call, - agentEventSink = my_event_sink -) -``` - -## Available Functions - -| Function | Description | -|----------|-------------| -| `loadTools(dir::String)` | Scan directory and load all `.jl` tool files | -| `registerTool(tool::agentTool)` | Register a single tool into the global registry | -| `getTools()` | Get deep copy of all registered tools | -| `listTools()` | List all registered tools as `(name, label)` pairs | -| `clearTools()` | Clear the global registry | - -## File Loading Order - -Files are sorted alphabetically before loading, so `01_database.jl` loads before `02_weather.jl`. This ensures deterministic registration order. diff --git a/src/tools/README.md b/src/tools/README.md index 9b7df7a..9e0c73c 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -399,15 +399,49 @@ The framework includes tools that allow the agent to create new tools at runtime ### `writeTool` — Create New Tool Files -The `writeTool` tool generates a new Julia tool file at `src/tools/.jl`. The agent writes the tool, then the agent (or system) restarts so `loadTools("src/tools")` picks it up. +`writeTool` is a **file writer**, not a code generator. The LLM provides the tool logic as `executeCode` (the actual Julia code), and `writeTool` wraps it in the required boilerplate. + +**How it works:** + +The LLM constructs `writeTool` with: +- **`executeCode`** — the actual tool logic (Julia code body, NOT wrapped in a function) +- **`name`, `label`, `description`** — tool metadata +- **`inputSchema`** — parameter schema in MCP format +- **`validateCode`, `prepareCode`** (optional) — custom validation/preparation logic + +`writeTool` produces `src/tools/.jl` by: +1. Converting the `inputSchema` Dict into a Julia `Dict{String,Any}(...)` string literal +2. Indenting `executeCode` with 4 spaces +3. Wrapping it inside a `function executeTool(...)::agentToolResult ... end` template +4. Appending the `getTool()` definition that returns an `agentTool` struct +5. Writing the combined string to disk **Workflow:** -1. Agent identifies a task that no existing tool can handle -2. Agent calls `writeTool` with a tool specification: +``` +LLM decides: "Need a searchWine tool. I'll provide the logic." + +LLM calls writeTool: + name: "searchWine" + executeCode: "query = args[\"query\"]\nresult = search(query)\nreturn ..." + +writeTool wraps it → src/tools/searchWine.jl: + function executeTool(...)::agentToolResult + query = args["query"] ← LLM code (indented 4 spaces) + result = search(query) + return agentToolResult(...) + end + + function getTool()::agentTool + return agentTool(name="searchWine", ...) + end + +Restart → loadTools("src/tools") loads searchWine.jl +``` + +**Example specification:** ```julia -# Agent sends this to writeTool: Dict( "name" => "searchWine", "label" => "Wine Search", @@ -431,45 +465,6 @@ Dict( ) ``` -3. Agent calls `listTools` to check for name collisions (built-in, auto-registered) -4. Agent calls `writeTool` with a unique name -5. Restart agent — `loadTools("src/tools")` picks up the new file -6. Agent's next LLM turn discovers and calls the new tool - -**Generated file format:** - -```julia -# Auto-generated tool: searchWine -# Generated by writeTool at 2026-08-08T14:00:00 - -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult - query = args["query"] - max_results = get(args, "maxResults", 10) - result = "Found 3 wines matching: $query" - return agentToolResult([textContent(result)], Dict{Any,Any}(), nothing, false) -end - -function getTool()::agentTool - return agentTool( - name = "searchWine", - label = "Wine Search", - description = "Search a wine database by name, region, or variety", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "query" => Dict("type" => "string", "description" => "Search query"), - "maxResults" => Dict("type" => "integer", "default" => 10) - ), - "required" => ["query"] - ), - execute = executeTool, - validateRequiredArgs = nothing, - prepareArguments = nothing, - parallelToolExecute = false - ) -end -``` - **Optional hooks:** | Field | Description | @@ -479,7 +474,7 @@ end ### `listTools` — Discover Available Tools -Returns a list of all registered tools with names, labels, and descriptions. +Returns all registered tools. Primarily useful for **collision detection** before creating a new tool via `writeTool` — the LLM checks existing names before picking a unique one. ```julia # Result from listTools: @@ -497,20 +492,18 @@ User: "I need to search for wines. Do you have a tool for that?" # ─── LOOP: Agent realizes no wine search tool exists ───────────────── -[Tool Call] listTools() -# Result: lists all available tools — no collision with existing tools +# LLM generates the tool logic and calls writeTool to write it to disk [Tool Call] writeTool(name="searchWine", label="Wine Search", description="Search a wine database by name, region, or variety", inputSchema={...}, executeCode="query = args[\"query\"]\nresult = \"Found wines...\"\nreturn agentToolResult([textContent(result)], ...)") -# writeTool generates searchWine.jl +# writeTool generates src/tools/searchWine.jl # ─── SYSTEM RESTARTS ───────────────────────────────────────────────── -# Agent restarts — loadTools("src/tools") picks up searchWine.jl -# searchWine is now available — agent uses it directly +# loadTools("src/tools") loads searchWine.jl alongside all other tools # ─── Agent calls the new tool ───────────────────────────────────────── diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl index a318232..fb2b969 100644 --- a/src/tools/writeTool.jl +++ b/src/tools/writeTool.jl @@ -1,13 +1,12 @@ """ -Tool that generates new Julia tool module files. +Tool that writes new Julia tool module files to disk. The agent can use this tool when it encounters a task that no existing tool can handle. Provide the tool's name, label, description, inputSchema, and execute logic as Julia code. The tool is written to `src/tools/.jl`. -After calling this tool, restart the agent so the new tool is loaded by -`loadTools("src/tools")`. Then call `listTools` to verify the new tool -is available. +After calling this tool, restart the agent so `loadTools("src/tools")` picks +up the new file. The new tool is immediately available. # Example @@ -16,6 +15,16 @@ is available. 3. Restart agent — loadTools() picks up the new file 4. Agent calls searchWine with args +# How It Works + +writeTool is a **file writer**, not a code generator. The LLM provides the +tool logic as `executeCode`, and writeTool wraps it in Julia boilerplate: + - Converts `inputSchema` Dict into Julia `Dict{String,Any}(...)` string + - Indents `executeCode` with 4 spaces + - Wraps it inside `function executeTool(...)::agentToolResult ... end` + - Appends `getTool()` returning an `agentTool` struct + - Writes the combined string to `src/tools/.jl` + # Important Notes - The `executeCode` string is embedded literally into the generated tool. @@ -243,7 +252,7 @@ function getTool()::agentTool return agentTool( name = "writeTool", label = "Create Tool", - description = "Generate a new Julia tool module file at src/tools/.jl. After creation, restart the agent and call listTools to verify the new tool is loaded.", + description = "Write a new Julia tool module file to src/tools/.jl. The LLM provides the tool logic as executeCode; writeTool wraps it in Julia boilerplate and writes the file. Restart the agent to load the new tool.", inputSchema = Dict{String,Any}( "type" => "object", "properties" => Dict( From 069240912b2f00d330026f9924699a2e84a4fd53 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 9 Aug 2026 04:45:31 +0700 Subject: [PATCH 5/8] update --- test/Manifest.toml | 41 ----------------------------------------- test/Project.toml | 2 -- 2 files changed, 43 deletions(-) delete mode 100644 test/Manifest.toml delete mode 100644 test/Project.toml diff --git a/test/Manifest.toml b/test/Manifest.toml deleted file mode 100644 index 83f035b..0000000 --- a/test/Manifest.toml +++ /dev/null @@ -1,41 +0,0 @@ -# This file is machine-generated - editing it directly is not advised - -julia_version = "1.11.4" -manifest_format = "2.0" -project_hash = "71d91126b5a1fb1020e1098d9d492de2a4438fd2" - -[[deps.Base64]] -uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" -version = "1.11.0" - -[[deps.InteractiveUtils]] -deps = ["Markdown"] -uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" -version = "1.11.0" - -[[deps.Logging]] -uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" -version = "1.11.0" - -[[deps.Markdown]] -deps = ["Base64"] -uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" -version = "1.11.0" - -[[deps.Random]] -deps = ["SHA"] -uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" -version = "1.11.0" - -[[deps.SHA]] -uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" -version = "0.7.0" - -[[deps.Serialization]] -uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" -version = "1.11.0" - -[[deps.Test]] -deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] -uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40" -version = "1.11.0" diff --git a/test/Project.toml b/test/Project.toml deleted file mode 100644 index 0c36332..0000000 --- a/test/Project.toml +++ /dev/null @@ -1,2 +0,0 @@ -[deps] -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" From da16c80a0ab1f2465f2aeef6971ce2ba9bf5a2be Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 9 Aug 2026 06:27:48 +0700 Subject: [PATCH 6/8] update --- Manifest.toml | 152 +++++++++++++++------------------------ Project.toml | 2 - src/agentCore.jl | 2 +- src/api.jl | 2 +- src/tools/registry.jl | 2 +- src/type.jl | 4 +- src_OLD/OLD_interface.jl | 2 +- src_OLD/llmfunction.jl | 2 +- test/loadToolTest.jl | 1 + 9 files changed, 66 insertions(+), 103 deletions(-) create mode 100644 test/loadToolTest.jl diff --git a/Manifest.toml b/Manifest.toml index 6fe654f..2e462e1 100644 --- a/Manifest.toml +++ b/Manifest.toml @@ -2,7 +2,7 @@ julia_version = "1.12.6" manifest_format = "2.0" -project_hash = "1c1379a2cec320abc347f3acb5ee815ba9855aa6" +project_hash = "0db36d4fb31037ba05065476e6aebaf4cd0e1e8c" [[deps.Accessors]] deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"] @@ -97,9 +97,9 @@ uuid = "944b1d66-785c-5afd-91f1-9de20f533193" version = "0.7.8" [[deps.CommonSolve]] -git-tree-sha1 = "eeaad7cef88554c2fa56b5a3f71cfd5cb708c662" +git-tree-sha1 = "cf963add2340ad9960e5eb22844e61ad8f931fe1" uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2" -version = "0.2.11" +version = "0.2.13" [[deps.Compat]] deps = ["TOML", "UUIDs"] @@ -146,9 +146,9 @@ version = "1.6.0" StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" [[deps.Crayons]] -git-tree-sha1 = "249fe38abf76d48563e2f4556bebd215aa317e15" +git-tree-sha1 = "54b76cbb40d9a0f5368c880725b2f141da77c94f" uuid = "a8cc5b0e-0ffa-5ad4-8c14-923d3ee1735f" -version = "4.1.1" +version = "4.2.0" [[deps.DBInterface]] git-tree-sha1 = "a444404b3f94deaa43ca2a58e18153a82695282b" @@ -168,9 +168,9 @@ version = "1.8.2" [[deps.DataStructures]] deps = ["OrderedCollections"] -git-tree-sha1 = "6fb53a69613a0b2b68a0d12671717d307ab8b24e" +git-tree-sha1 = "b0bc6d2cad1fed8b7fd59a1551a991cb3d2809e6" uuid = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" -version = "0.19.5" +version = "0.19.6" [[deps.DataValueInterfaces]] git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6" @@ -208,9 +208,9 @@ version = "1.11.0" [[deps.Distributions]] deps = ["AliasTables", "FillArrays", "LinearAlgebra", "PDMats", "Printf", "QuadGK", "Random", "Roots", "SpecialFunctions", "Statistics", "StatsAPI", "StatsBase", "StatsFuns"] -git-tree-sha1 = "cd3c5ac74cd3923c8945c6a81518c46abd0e73a3" +git-tree-sha1 = "d2facc77c08c1c2bfb1a77c148edd05b3db5410b" uuid = "31c24e10-a181-5473-b8eb-7969acd0382f" -version = "0.25.129" +version = "0.25.130" [deps.Distributions.extensions] DistributionsChainRulesCoreExt = "ChainRulesCore" @@ -240,15 +240,9 @@ uuid = "4e289a0a-7415-4d19-859d-a7e5c4648b56" version = "1.0.7" [[deps.ExprTools]] -git-tree-sha1 = "27415f162e6028e81c72b82ef756bf321213b6ec" +git-tree-sha1 = "d2e49e7efd29719d6f28b891b0e0e159daa9d2b4" uuid = "e2ba6199-217a-4e67-a87a-7c52f15ade04" -version = "0.1.10" - -[[deps.EzXML]] -deps = ["Printf", "XML2_jll"] -git-tree-sha1 = "7ea1aa5869e2626ccae84480e4f37185bc6f41d3" -uuid = "8f5d6c58-4d21-5cfd-889c-e3ad7ee6a615" -version = "1.2.3" +version = "0.1.11" [[deps.FileIO]] deps = ["Pkg", "Requires", "UUIDs"] @@ -277,9 +271,9 @@ version = "1.11.0" [[deps.FillArrays]] deps = ["LinearAlgebra"] -git-tree-sha1 = "2f979084d1e13948a3352cf64a25df6bd3b4dca3" +git-tree-sha1 = "5bad39456d9f0166184fce2248783dd9862645c1" uuid = "1a297f60-69ca-5386-bcde-b61e274b549b" -version = "1.16.0" +version = "1.17.0" weakdeps = ["PDMats", "SparseArrays", "StaticArrays", "Statistics"] [deps.FillArrays.extensions] @@ -300,11 +294,11 @@ version = "1.1.0" [[deps.GeneralUtils]] deps = ["CSV", "DataFrames", "DataStructures", "Dates", "Distributions", "Graphs", "HTTP", "JSON", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "SHA", "StringDistances", "UUIDs"] -git-tree-sha1 = "93293126d24d3929ef6a5067f347bc28c6582c71" +git-tree-sha1 = "129b8fa1bf3bf6d8c0a080f39db09b9b986ef8de" repo-rev = "main" repo-url = "https://git.yiem.cc/ton/GeneralUtils" uuid = "c6c72f09-b708-4ac8-ac7c-2084d70108fe" -version = "0.5.10" +version = "0.5.11" [[deps.Graphs]] deps = ["ArnoldiMethod", "DataStructures", "Inflate", "LinearAlgebra", "Random", "SimpleTraits", "SparseArrays", "Statistics"] @@ -321,9 +315,9 @@ version = "1.14.0" [[deps.HTTP]] deps = ["Base64", "CodecZlib", "Dates", "EnumX", "PrecompileTools", "Random", "Reseau", "SHA", "URIs", "UUIDs", "Zlib_jll"] -git-tree-sha1 = "c2c808326222b6dc4bec295a83b55f79aeec98e0" +git-tree-sha1 = "0a58fbbdee93d132a2fb1159b7f5e1b5c2465e71" uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3" -version = "2.5.5" +version = "2.6.4" [[deps.HashArrayMappedTries]] git-tree-sha1 = "2eaa69a7cab70a52b9687c8bf950a5a93ec895ae" @@ -332,9 +326,9 @@ version = "0.2.0" [[deps.HypergeometricFunctions]] deps = ["Gamma", "LinearAlgebra"] -git-tree-sha1 = "18d7deab5fb0440dc6a7b6993c5c27b25420de10" +git-tree-sha1 = "31bb6c92405c084617facc1d7ed9eb6c402d061e" uuid = "34004b35-14d8-5ef3-9330-4cdb6864b03a" -version = "0.3.29" +version = "0.3.30" [[deps.ICU_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] @@ -412,9 +406,9 @@ version = "1.8.0" [[deps.JSON]] deps = ["Dates", "Logging", "Parsers", "PrecompileTools", "StructUtils", "UUIDs", "Unicode"] -git-tree-sha1 = "c89d196f5ffb64bfbf80985b699ea913b0d2c211" +git-tree-sha1 = "65979512c25a0727f050e6e4be40f0fd9ec893f7" uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -version = "1.6.1" +version = "1.7.0" weakdeps = ["ArrowTypes"] [deps.JSON.extensions] @@ -432,9 +426,9 @@ weakdeps = ["ArrowTypes"] [[deps.JuliaInterpreter]] deps = ["CodeTracking", "InteractiveUtils", "Random", "UUIDs"] -git-tree-sha1 = "58927c485919bf17ea308d9d82156de1adf4b006" +git-tree-sha1 = "c3d401f110454b4ea24a76be33f6ee0d7d385103" uuid = "aa1ae85d-cabe-5617-a682-6adf51b2e16a" -version = "0.10.12" +version = "0.11.4" [[deps.JuliaSyntaxHighlighting]] deps = ["StyledStrings"] @@ -506,12 +500,6 @@ version = "1.11.3+1" uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" version = "1.11.0" -[[deps.Libiconv_jll]] -deps = ["Artifacts", "JLLWrappers", "Libdl"] -git-tree-sha1 = "be484f5c92fad0bd8acfef35fe017900b0b73809" -uuid = "94ce4f54-9a6c-5748-9c1c-f9c7231a4531" -version = "1.18.0+0" - [[deps.LinearAlgebra]] deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"] uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" @@ -539,9 +527,9 @@ version = "1.11.0" [[deps.LoweredCodeUtils]] deps = ["CodeTracking", "Compiler", "JuliaInterpreter"] -git-tree-sha1 = "3733419e9a71156b389f3e331672d2e95436783f" +git-tree-sha1 = "1d4c737ab26f51ceed52ab2019c09b7660eb7440" uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b" -version = "3.6.2" +version = "3.8.0" [[deps.MacroTools]] git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522" @@ -599,9 +587,9 @@ version = "0.1.1" [[deps.NanoDates]] deps = ["Dates", "Parsers"] -git-tree-sha1 = "850a0557ae5934f6e67ac0dc5ca13d0328422d1f" +git-tree-sha1 = "77c7e98ca39aefb481f9b97a2f4f5c5471c08a1d" uuid = "46f1a544-deae-4307-8689-c12aa3c955c6" -version = "1.0.3" +version = "1.1.0" [[deps.NetworkOptions]] uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" @@ -640,15 +628,15 @@ uuid = "efe28fd5-8261-553b-a9e1-b2916fc3738e" version = "0.5.6+0" [[deps.OrderedCollections]] -git-tree-sha1 = "94ba93778373a53bfd5a0caaf7d809c445292ff4" +git-tree-sha1 = "05f45c2e0de6259db764adbfd2f1dc6d3f8de13c" uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" -version = "1.8.2" +version = "2.0.1" [[deps.PDMats]] deps = ["LinearAlgebra", "SparseArrays", "SuiteSparse"] -git-tree-sha1 = "26766d4b5f1a410c218a19b85a672c6edb693c65" +git-tree-sha1 = "123266c25174ef6c8d4718920abc206452cf8de6" uuid = "90014a1f-27ba-587c-ab20-58faa44d9150" -version = "0.11.40" +version = "0.11.41" weakdeps = ["StatsBase"] [deps.PDMats.extensions] @@ -656,9 +644,9 @@ weakdeps = ["StatsBase"] [[deps.Parsers]] deps = ["Dates", "PrecompileTools", "UUIDs"] -git-tree-sha1 = "32a4e09c5f29402573d673901778a0e03b0807b9" +git-tree-sha1 = "3de8f5e6e90ebfa8d6d1f86997d6cdcd6a912ff3" uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" -version = "2.8.6" +version = "2.8.7" [[deps.Pkg]] deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "Random", "SHA", "TOML", "Tar", "UUIDs", "p7zip_jll"] @@ -688,15 +676,15 @@ uuid = "21216c6a-2e73-6563-6e65-726566657250" version = "1.5.2" [[deps.PrettyPrinting]] -git-tree-sha1 = "142ee93724a9c5d04d78df7006670a93ed1b244e" +git-tree-sha1 = "0b7f4ad437e31c51cf5b91fb103579b04025170a" uuid = "54e16d92-306c-5ea0-a30b-337be88ac337" -version = "0.4.2" +version = "0.4.3" [[deps.PrettyTables]] deps = ["Crayons", "LaTeXStrings", "Markdown", "PrecompileTools", "Printf", "REPL", "Reexport", "StringManipulation", "Tables"] -git-tree-sha1 = "ebf455bb866ee6737030e3d3816bb6a0683c4325" +git-tree-sha1 = "4ac881f5432bd93463a41767a814a45245be22b6" uuid = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" -version = "3.4.0" +version = "3.4.6" [deps.PrettyTables.extensions] PrettyTablesExcelExt = "XLSX" @@ -757,15 +745,15 @@ version = "1.3.1" [[deps.Reseau]] deps = ["NetworkOptions", "OpenSSL_jll", "PrecompileTools", "Random", "SHA"] -git-tree-sha1 = "0eab6d95ed40c2ef3992255c1c71e4f9748932b5" +git-tree-sha1 = "701ef63506992668c0069a4d1ac1540b77258e6c" uuid = "802f3686-a58f-41ce-bb0c-3c43c75bba36" -version = "1.3.1" +version = "1.3.6" [[deps.Revise]] -deps = ["CRC32c", "CodeTracking", "FileWatching", "InteractiveUtils", "JuliaInterpreter", "LibGit2", "LoweredCodeUtils", "OrderedCollections", "Preferences", "REPL", "UUIDs"] -git-tree-sha1 = "27e3ee13fc8739a59b380d6163d6a82f52c03bd7" +deps = ["CRC32c", "CodeTracking", "FileWatching", "JuliaInterpreter", "LibGit2", "LoweredCodeUtils", "OrderedCollections", "Preferences", "REPL", "UUIDs"] +git-tree-sha1 = "ec46aed6a3a8cc6b67839ca361e7b4aa32eaeee1" uuid = "295af30f-e4ad-537b-8983-00126c2a3abe" -version = "3.15.1" +version = "3.16.3" weakdeps = ["Distributed"] [deps.Revise.extensions] @@ -779,15 +767,15 @@ version = "0.9.0" [[deps.Rmath_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] -git-tree-sha1 = "58cdd8fb2201a6267e1db87ff148dd6c1dbd8ad8" +git-tree-sha1 = "6d40b2fe70437b01397d2a4d5b020008da4e7019" uuid = "f50d1b31-88e8-58de-be2c-1cc44531875f" -version = "0.5.1+0" +version = "0.5.2+0" [[deps.Roots]] deps = ["Accessors", "CommonSolve", "Printf"] -git-tree-sha1 = "a7caaf7ba8cf307112ca443784d1b56b4a591455" +git-tree-sha1 = "7fb25a964849d90a0446366cdefca822e0e84900" uuid = "f2b01f46-fcfa-551c-844a-d8ac1e96c665" -version = "3.0.5" +version = "3.0.6" [deps.Roots.extensions] RootsChainRulesCoreExt = "ChainRulesCore" @@ -840,12 +828,6 @@ git-tree-sha1 = "084c47c7c5ce5cfecefa0a98dff69eb3646b5a80" uuid = "91c51154-3ec4-41a3-a24f-3f23e20d615c" version = "1.4.10" -[[deps.Serde]] -deps = ["CSV", "Dates", "EzXML", "JSON", "TOML", "UUIDs", "YAML"] -git-tree-sha1 = "f397fc8779cc53e4677c2708f3802c6996f28d00" -uuid = "db9b398d-9517-45f8-9a95-92af99003e0e" -version = "3.7.2" - [[deps.Serialization]] uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" version = "1.11.0" @@ -879,9 +861,9 @@ version = "1.12.0" [[deps.SpecialFunctions]] deps = ["IrrationalConstants", "LogExpFunctions", "OpenLibm_jll", "OpenSpecFun_jll"] -git-tree-sha1 = "6547cbdd8ce32efba0d21c5a40fa96d1a3548f9f" +git-tree-sha1 = "c3ac026e735264e9bdc6a9bcbd1b1e781b36e3bc" uuid = "276daf66-3868-5448-9aa4-cd146d93841b" -version = "2.8.0" +version = "2.8.3" [deps.SpecialFunctions.extensions] SpecialFunctionsChainRulesCoreExt = "ChainRulesCore" @@ -932,9 +914,9 @@ version = "0.34.12" [[deps.StatsFuns]] deps = ["HypergeometricFunctions", "IrrationalConstants", "LogExpFunctions", "Reexport", "Rmath", "SpecialFunctions"] -git-tree-sha1 = "770240df9a3b8888065046948f7a09b4e0f997d5" +git-tree-sha1 = "91a5737baed20ee31f3faea0e51f57461f6a689e" uuid = "4c63d2b9-4356-54db-8cca-17b64c39e42c" -version = "2.2.0" +version = "2.2.1" [deps.StatsFuns.extensions] StatsFunsChainRulesCoreExt = "ChainRulesCore" @@ -950,17 +932,11 @@ git-tree-sha1 = "cd83a04baf746e3b43b83c61b7de77ab0409b80a" uuid = "88034a9c-02f8-509d-84a9-84ec65e18404" version = "1.0.0" -[[deps.StringEncodings]] -deps = ["Libiconv_jll"] -git-tree-sha1 = "b765e46ba27ecf6b44faf70df40c57aa3a547dcb" -uuid = "69024149-9ee7-55f6-a4c4-859efe599b68" -version = "0.3.7" - [[deps.StringManipulation]] deps = ["PrecompileTools"] -git-tree-sha1 = "d05693d339e37d6ab134c5ab53c29fce5ee5d7d5" +git-tree-sha1 = "8a90c1d77c3277a5d43b83927b3cbe2c70a37484" uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e" -version = "0.4.4" +version = "0.4.7" [[deps.StructTypes]] deps = ["Dates", "UUIDs"] @@ -970,9 +946,9 @@ version = "1.11.0" [[deps.StructUtils]] deps = ["Dates", "UUIDs"] -git-tree-sha1 = "82bee338d650aa515f31866c460cb7e3bcef90b8" +git-tree-sha1 = "c65ae4aa47e543c278aea0a3468786d33021a3ff" uuid = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42" -version = "2.8.2" +version = "2.8.4" [deps.StructUtils.extensions] StructUtilsMeasurementsExt = ["Measurements"] @@ -1046,9 +1022,9 @@ uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa" version = "0.11.3" [[deps.URIs]] -git-tree-sha1 = "bef26fb046d031353ef97a82e3fdb6afe7f21b1a" +git-tree-sha1 = "3b0738bd7c5645641845da25cbd99800b8718689" uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" -version = "1.6.1" +version = "1.6.2" [[deps.UTCDateTimes]] deps = ["Dates", "TimeZones"] @@ -1076,23 +1052,11 @@ git-tree-sha1 = "cd1659ba0d57b71a464a29e64dbc67cfe83d54e7" uuid = "76eceee3-57b5-4d4a-8e66-0e911cebbf60" version = "1.6.1" -[[deps.XML2_jll]] -deps = ["Artifacts", "JLLWrappers", "Libdl", "Libiconv_jll", "Zlib_jll"] -git-tree-sha1 = "3f3315d89fc954a28f5b471bce698ed6e27481be" -uuid = "02c8fc9c-b97f-50b9-bbe4-9be30ff0a78a" -version = "2.15.3+0" - -[[deps.YAML]] -deps = ["Base64", "Dates", "Printf", "StringEncodings"] -git-tree-sha1 = "a1c0c7585346251353cddede21f180b96388c403" -uuid = "ddb6d928-2868-570f-bddf-ab3f9cf99eb6" -version = "0.4.16" - [[deps.YiemAgent]] -deps = ["Base64", "CSV", "DataFrames", "DataStructures", "Dates", "HTTP", "JSON", "LLMMCTS", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "SQLLLM", "Serde", "Serialization", "URIs", "UUIDs"] +deps = ["Base64", "CSV", "DataFrames", "DataStructures", "Dates", "GeneralUtils", "HTTP", "JSON", "LLMMCTS", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "SQLLLM", "Serialization", "URIs", "UUIDs"] path = "." uuid = "e012c34b-7f78-48e0-971c-7abb83b6f0a2" -version = "0.7.4" +version = "0.8.0" [[deps.Zlib_jll]] deps = ["Libdl"] diff --git a/Project.toml b/Project.toml index 4a4df5f..f687431 100644 --- a/Project.toml +++ b/Project.toml @@ -19,7 +19,6 @@ PrettyPrinting = "54e16d92-306c-5ea0-a30b-337be88ac337" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Revise = "295af30f-e4ad-537b-8983-00126c2a3abe" SQLLLM = "2ebc79c7-cc10-4a3a-9665-d2e1d61e63d3" -Serde = "db9b398d-9517-45f8-9a95-92af99003e0e" Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" @@ -34,4 +33,3 @@ JSON = "1.6.1" LLMMCTS = "0.1.5" NATS = "0.1.0" SQLLLM = "0.2.8" -Serde = "3.7.2" diff --git a/src/agentCore.jl b/src/agentCore.jl index 0ee5916..852d145 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -3,7 +3,7 @@ module agentCore export _agent_loop, OpenAiToUserMessage using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, - DataFrames, Serde, Base.Threads + DataFrames, Base.Threads using GeneralUtils using ..type, ..utils diff --git a/src/api.jl b/src/api.jl index ec8ece2..b126b19 100644 --- a/src/api.jl +++ b/src/api.jl @@ -3,7 +3,7 @@ module api export prompt using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, - DataFrames, Serde + DataFrames using GeneralUtils using ..type, ..utils diff --git a/src/tools/registry.jl b/src/tools/registry.jl index 65b1085..e7d691d 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -74,7 +74,7 @@ function getTool()::agentTool execute = (toolCallId, args, signal, onPartialResult) -> begin city = args["city"] return agentToolResult( - [textContent("Sunny, 22C in $(city)")], + [textContent("Sunny, 22C in Bangkok")], Dict{Any,Any}(), nothing, false ) end, diff --git a/src/type.jl b/src/type.jl index 387d49c..33597df 100644 --- a/src/type.jl +++ b/src/type.jl @@ -9,7 +9,7 @@ # Message types userMessage, assistantMessage, toolResultMessage, # Tool types - agentTool, validateRequiredArgs + agentTool, validateRequiredArgs, # Context types agentContext, agentState, agentToolCall, prepareNextTurnContext, # Loop & execution types @@ -248,7 +248,7 @@ tool = agentTool( execute=(toolCallId, args, signal, onPartialResult) -> begin city = args["city"] return agentToolResult( - [textContent("Sunny, 22C in $(city)")], + [textContent("Sunny, 22C in Bangkok")], Dict{Any,Any}(), nothing, false ) end, diff --git a/src_OLD/OLD_interface.jl b/src_OLD/OLD_interface.jl index 5e375d6..fc74a9f 100644 --- a/src_OLD/OLD_interface.jl +++ b/src_OLD/OLD_interface.jl @@ -4,7 +4,7 @@ export addNewMessage, conversation, decisionMaker, reflector, generatechat, generalconversation, detectWineryName, generateSituationReport using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, - DataFrames, Serde + DataFrames using GeneralUtils using ..type, ..util, ..llmfunction diff --git a/src_OLD/llmfunction.jl b/src_OLD/llmfunction.jl index 2cc1b3d..e7c8903 100644 --- a/src_OLD/llmfunction.jl +++ b/src_OLD/llmfunction.jl @@ -5,7 +5,7 @@ export virtualWineUserChatbox, jsoncorrection, search_wine_database!, # recomme extractWineAttributes_2, paraphrase, SQLexecution using HTTP, JSON, URIs, Random, PrettyPrinting, UUIDs, Dates, DataFrames, DataStructures, - Base64, Serde, LibPQ, NATS + Base64, LibPQ, NATS using GeneralUtils, SQLLLM using ..type, ..util diff --git a/test/loadToolTest.jl b/test/loadToolTest.jl new file mode 100644 index 0000000..071a6ec --- /dev/null +++ b/test/loadToolTest.jl @@ -0,0 +1 @@ +using YiemAgent \ No newline at end of file From 6b3d575ea07cf8e94a396d86dc28122adb56e149 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 9 Aug 2026 06:50:37 +0700 Subject: [PATCH 7/8] update --- src/tools/registry.jl | 3 +- src/type.jl | 11 +++ test/loadToolTest.jl | 180 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 192 insertions(+), 2 deletions(-) diff --git a/src/tools/registry.jl b/src/tools/registry.jl index e7d691d..b828793 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -119,7 +119,8 @@ function loadTools(dir::String)::Vector{agentTool} end # Call getTool() — it runs in current scope where types are visible - tool = getTool() + # Use invokelatest to handle world-age semantics after include() + tool = invokelatest(getTool) if !(tool isa agentTool) throw(ArgumentError( "getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))" diff --git a/src/type.jl b/src/type.jl index 33597df..7078168 100644 --- a/src/type.jl +++ b/src/type.jl @@ -269,6 +269,17 @@ struct agentTool # A tool available to the agent parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel end +""" +Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...)`. +""" +function agentTool(; name::String, label::String, description::String, inputSchema::Any, + execute::Function, prepareArguments::Union{Function, Nothing}=nothing, + validateRequiredArgs::Union{Function, Nothing}=nothing, + parallelToolExecute::Bool=false) + return agentTool(name, label, description, inputSchema, execute, + prepareArguments, validateRequiredArgs, parallelToolExecute) +end + # ------------------------------------------------------------------------------------------------ # # Agent context # diff --git a/test/loadToolTest.jl b/test/loadToolTest.jl index 071a6ec..d228efe 100644 --- a/test/loadToolTest.jl +++ b/test/loadToolTest.jl @@ -1 +1,179 @@ -using YiemAgent \ No newline at end of file +using Test +using YiemAgent +using YiemAgent.toolRegistry +using YiemAgent.type + +# ------------------------------------------------------------------ # +# loadTools() unit tests # +# ------------------------------------------------------------------ # + +@testset "loadTools" begin + + # ------------------------------------------------------------------ # + # 1. loadTools throws on non-existent directory # + # ------------------------------------------------------------------ # + @test_throws ArgumentError loadTools("/nonexistent/dir/that/does/not/exist") + + # ------------------------------------------------------------------ # + # 2. loadTools throws if a .jl file does not define getTool() # + # Must run before any other loadTools call (getTool persists in # + # module scope after include()). # + # ------------------------------------------------------------------ # + bad_dir = mktempdir() + write(joinpath(bad_dir, "noTool.jl"), "x = 42\n") + @test_throws ArgumentError loadTools(bad_dir) + + # ------------------------------------------------------------------ # + # 3. loadTools loads tool files that define getTool() # + # ------------------------------------------------------------------ # + tmpdir = mktempdir() + + # Create a valid tool file (must use bare type names — include() places file in toolRegistry scope) + valid_tool_echo = """ +function getTool()::agentTool + return agentTool( + name = "testEcho", + label = "Echo Test", + description = "Echoes the input argument", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict("message" => Dict("type" => "string")), + "required" => Any["message"] + ), + execute = (toolCallId, args, signal, onPartialResult) -> begin + return agentToolResult( + [textContent("echo: " * string(args["message"]))], + Dict{Any,Any}(), nothing, false + ) + end, + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false + ) +end +""" + write(joinpath(tmpdir, "getEcho.jl"), valid_tool_echo) + + loaded = loadTools(tmpdir) + @test !isempty(loaded) + @test length(loaded) >= 1 + + names = [t.name for t in loaded] + @test "testEcho" in names + + # Check agentTool fields + echo_tool = filter(t -> t.name == "testEcho", loaded) + @test !isempty(echo_tool) + @test echo_tool[1].label == "Echo Test" + @test echo_tool[1].description == "Echoes the input argument" + @test echo_tool[1].parallelToolExecute == false + @test echo_tool[1].execute !== nothing + + # ------------------------------------------------------------------ # + # 4. loadTools returns tools sorted alphabetically # + # ------------------------------------------------------------------ # + sorted_dir = mktempdir() + + tool_a = """ +function getTool()::agentTool + return agentTool( + name = "alphaTool", + label = "Alpha Tool", + description = "First tool", + inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]), + execute = (toolCallId, args, signal, onPartialResult) -> + agentToolResult([textContent("alpha")], Dict{Any,Any}(), nothing, false), + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false + ) +end +""" + + tool_m = """ +function getTool()::agentTool + return agentTool( + name = "midTool", + label = "Mid Tool", + description = "Middle tool", + inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]), + execute = (toolCallId, args, signal, onPartialResult) -> + agentToolResult([textContent("mid")], Dict{Any,Any}(), nothing, false), + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false + ) +end +""" + + tool_z = """ +function getTool()::agentTool + return agentTool( + name = "zuluTool", + label = "Zulu Tool", + description = "Last tool", + inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]), + execute = (toolCallId, args, signal, onPartialResult) -> + agentToolResult([textContent("zulu")], Dict{Any,Any}(), nothing, false), + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false + ) +end +""" + + write(joinpath(sorted_dir, "zTool.jl"), tool_z) + write(joinpath(sorted_dir, "aTool.jl"), tool_a) + write(joinpath(sorted_dir, "mTool.jl"), tool_m) + + loaded_sorted = loadTools(sorted_dir) + # loadTools returns only tools loaded from the directory, in file-sorted order + @test length(loaded_sorted) == 3 + @test loaded_sorted[1].name == "alphaTool" + @test loaded_sorted[2].name == "midTool" + @test loaded_sorted[3].name == "zuluTool" + + # ------------------------------------------------------------------ # + # 5. getTools returns a deep copy (mutations don't affect registry) # + # ------------------------------------------------------------------ # + registry_tools = getTools() + @test !isempty(registry_tools) + orig_count = length(registry_tools) + + # Clear and add a new tool via registerTool + clearTools() + registry_after_clear = getTools() + @test isempty(registry_after_clear) + + # ------------------------------------------------------------------ # + # 6. registerTool adds to global registry # + # ------------------------------------------------------------------ # + test_tool = agentTool( + name = "manualTool", + label = "Manual Tool", + description = "Registered manually", + inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]), + execute = (toolCallId, args, signal, onPartialResult) -> + agentToolResult([textContent("manual")], Dict{Any,Any}(), nothing, false), + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = true + ) + registerTool(test_tool) + reg = getTools() + @test any(t -> t.name == "manualTool", reg) + @test count(t -> t.name == "manualTool", reg) == 1 + + # parallelToolExecute flag + manual_entry = filter(t -> t.name == "manualTool", reg) + @test manual_entry[1].parallelToolExecute == true + + # ------------------------------------------------------------------ # + # 7. getTools returns deep copy # + # ------------------------------------------------------------------ # + copy1 = getTools() + copy2 = getTools() + @test copy1 !== copy2 + empty!(copy1) + @test !isempty(getTools()) +end From ed5415d92a41568eb9c1a6662bf9b66a2287b995 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 9 Aug 2026 08:45:35 +0700 Subject: [PATCH 8/8] update --- src/tools/getTime.jl | 47 +++---- src/tools/getWeather.jl | 41 ++---- src/tools/registry.jl | 4 +- src/tools/writeTool.jl | 285 ++++++++++++++++++++-------------------- test/loadToolTest.jl | 195 +++++++++++---------------- 5 files changed, 243 insertions(+), 329 deletions(-) diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl index 97636ab..0d6a467 100644 --- a/src/tools/getTime.jl +++ b/src/tools/getTime.jl @@ -36,35 +36,6 @@ function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} return nothing end -""" -Execute the getTime tool. - -# Arguments -- `toolCallId::String`: Unique identifier for this tool call -- `args::Dict{String,Any}`: Parsed arguments from the LLM -- `signal::Union{Nothing,abortSignal}`: Optional abort signal -- `onPartialResult::Function`: Callback for streaming partial results - -# Returns -- `agentToolResult`: Result content with current time data -""" -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult - tz = get(args, "timezone", nothing) - city = get(args, "city", "") - - # Simulate time lookup — replace with actual timezone API call - if tz !== nothing - result = "Current time in $(tz): $(now())" - else - result = "Current time in $(city): $(now())" - end - - return agentToolResult( - [textContent(result)], - Dict{Any,Any}(), nothing, false - ) -end - """ Define and return the getTime agentTool. """ @@ -76,12 +47,24 @@ function getTool()::agentTool inputSchema = Dict{String,Any}( "type" => "object", "properties" => Dict( - "timezone" => Dict("type" => "string", "description", "IANA timezone, e.g. 'America/New_York'"), - "city" => Dict("type" => "string", "description", "City name as fallback") + "timezone" => Dict("type" => "string", "description" => "IANA timezone, e.g. 'America/New_York'"), + "city" => Dict("type" => "string", "description" => "City name as fallback") ), "required" => [] ), - execute = executeTool, + execute = (toolCallId, args, signal, onPartialResult) -> begin + tz = get(args, "timezone", nothing) + city = get(args, "city", "") + if tz !== nothing + result = "Current time in $(tz): $(now())" + else + result = "Current time in $(city): $(now())" + end + return agentToolResult( + [textContent(result)], + Dict{Any,Any}(), nothing, false + ) + end, prepareArguments = nothing, validateRequiredArgs = validateRequiredArgs, parallelToolExecute = false diff --git a/src/tools/getWeather.jl b/src/tools/getWeather.jl index 0411841..61a17f5 100644 --- a/src/tools/getWeather.jl +++ b/src/tools/getWeather.jl @@ -1,33 +1,3 @@ -""" -Execute the getWeather tool. - -# Arguments -- `toolCallId::String`: Unique identifier for this tool call -- `args::Dict{String,Any}`: Parsed arguments from the LLM -- `signal::Union{Nothing,abortSignal}`: Optional abort signal -- `onPartialResult::Function`: Callback for streaming partial results - -# Returns -- `agentToolResult`: Result content with weather data -""" -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult - city = get(args, "city", "") - units = get(args, "units", "celsius") - - # Simulate weather fetch — replace with actual API call - # You can call onPartialResult() here for streaming progress updates: - # onPartialResult(Dict("status" => "Fetching weather data...")) - # onPartialResult(Dict("status" => "Processing...")) - - temp = units == "fahrenheit" ? "72" : "22" - unit_symbol = units == "celsius" ? "°C" : "°F" - - return agentToolResult( - [textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")], - Dict{Any,Any}(), nothing, false - ) -end - """ Define and return the getWeather agentTool. """ @@ -44,7 +14,16 @@ function getTool()::agentTool ), "required" => ["city"] ), - execute = executeTool, # reference the function defined above + execute = (toolCallId, args, signal, onPartialResult) -> begin + city = get(args, "city", "") + units = get(args, "units", "celsius") + temp = units == "fahrenheit" ? "72" : "22" + unit_symbol = units == "celsius" ? "°C" : "°F" + return agentToolResult( + [textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")], + Dict{Any,Any}(), nothing, false + ) + end, prepareArguments = nothing, validateRequiredArgs = nothing, parallelToolExecute = false diff --git a/src/tools/registry.jl b/src/tools/registry.jl index b828793..f7aa585 100644 --- a/src/tools/registry.jl +++ b/src/tools/registry.jl @@ -2,6 +2,8 @@ module toolRegistry export loadTools, registerTool, getTools, clearTools +using Dates +using JSON using ..type # Global registry — populated at runtime by loadTools() or registerTool() @@ -99,7 +101,7 @@ function loadTools(dir::String)::Vector{agentTool} end tools = agentTool[] - jl_files = filter(f -> endswith(f, ".jl"), readdir(dir)) + jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir)) sort!(jl_files) for filename in jl_files diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl index fb2b969..40c4d8c 100644 --- a/src/tools/writeTool.jl +++ b/src/tools/writeTool.jl @@ -45,151 +45,6 @@ function validateToolName(name::String)::Union{Nothing,String} return nothing end -""" -Execute the writeTool. - -Generates a new .jl tool file and registers it with the tool registry. -""" -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult - tool_name = get(args, "name", "")::String - tool_label = get(args, "label", tool_name)::String - tool_description = get(args, "description", "")::String - tool_schema = get(args, "inputSchema", Dict{String,Any}())::Dict{String,Any} - execute_code = get(args, "executeCode", "")::String - validate_code = get(args, "validateCode", nothing)::Union{String,Nothing} - prepare_code = get(args, "prepareCode", nothing)::Union{String,Nothing} - parallel = get(args, "parallel", false)::Bool - - # Validate tool name - name_err = validateToolName(tool_name) - if name_err !== nothing - return agentToolResult( - [textContent(name_err)], - Dict{Any,Any}(), nothing, false - ) - end - - # Validate required fields - if isempty(tool_name) - return agentToolResult( - [textContent("Missing required field: 'name'")], - Dict{Any,Any}(), nothing, false - ) - end - if isempty(tool_description) - return agentToolResult( - [textContent("Missing required field: 'description'")], - Dict{Any,Any}(), nothing, false - ) - end - if isempty(execute_code) - return agentToolResult( - [textContent("Missing required field: 'executeCode'")], - Dict{Any,Any}(), nothing, false - ) - end - - onPartialResult(Dict("status" => "Generating tool: $tool_name")) - - # Build the tool file path - script_dir = dirname(@__FILE__) - tools_dir = dirname(script_dir) - filepath = joinpath(tools_dir, "$(tool_name).jl") - - # Check for naming conflicts - if isfile(filepath) - return agentToolResult( - [textContent("Tool file already exists: $filepath. Rename the tool or delete the existing file first.")], - Dict{Any,Any}(), nothing, false - ) - end - - onPartialResult(Dict("status" => "Writing file: $(basename(filepath))")) - - # Convert schema Dict to a Julia Dict literal string - schema_literal = dict_to_julia_literal(tool_schema) - - # Build optional validation function - validate_section = if validate_code !== nothing && !isempty(validate_code) - indented = indent_code(validate_code, 4) - "function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}\n$indented\n return nothing\nend\n" - else - "" - end - - # Build optional prepare function - prepare_section = if prepare_code !== nothing && !isempty(prepare_code) - indented = indent_code(prepare_code, 4) - "function prepareArguments(args::Dict{String,Any})::Dict{String,Any}\n$indented\n return args\nend\n" - else - "" - end - - # Indent user's execute code for embedding inside execute function body - indented_exec = indent_code(execute_code, 4) - - # Escape description for Julia string literal - escaped_desc = replace(tool_description, "\\" => "\\\\") - escaped_desc = replace(escaped_desc, "\"" => "\\\"") - - # Build the complete tool file content - parts = String[] - push!(parts, "# Auto-generated tool: $tool_name\n") - push!(parts, "# Generated by writeTool at $(now())\n\n") - if !isempty(validate_section) - push!(parts, validate_section) - push!(parts, "\n") - end - if !isempty(prepare_section) - push!(parts, prepare_section) - push!(parts, "\n") - end - push!(parts, "\n") - push!(parts, "# Execute function\n") - push!(parts, "function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult\n") - push!(parts, "$indented_exec\n") - push!(parts, "end\n\n") - push!(parts, "# Tool definition\n") - push!(parts, "function getTool()::agentTool\n") - push!(parts, " return agentTool(\n") - push!(parts, " name = \"$(tool_name)\",\n") - push!(parts, " label = \"$(tool_label)\",\n") - push!(parts, " description = \"$(escaped_desc)\",\n") - push!(parts, " inputSchema = $schema_literal,\n") - push!(parts, " execute = executeTool,\n") - if validate_code !== nothing && !isempty(validate_code) - push!(parts, " validateRequiredArgs = validateRequiredArgs,\n") - else - push!(parts, " validateRequiredArgs = nothing,\n") - end - if prepare_code !== nothing && !isempty(prepare_code) - push!(parts, " prepareArguments = prepareArguments,\n") - else - push!(parts, " prepareArguments = nothing,\n") - end - push!(parts, " parallelToolExecute = $parallel\n") - push!(parts, " )\n") - push!(parts, "end\n") - - tool_code = join(parts) - - # Write the file — tool is loaded on next agent restart via loadTools() - write(filepath, tool_code) - - onPartialResult(Dict("status" => "Done")) - - return agentToolResult( - [textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools() picks it up, then call listTools to verify.")], - Dict{Any,Any}( - "file" => filepath, - "name" => tool_name, - "label" => tool_label, - "description" => tool_description, - ), - nothing, false - ) -end - """ Indent a multi-line code string by the specified number of spaces. """ @@ -270,7 +125,145 @@ function getTool()::agentTool ), "required" => ["name", "label", "description", "inputSchema", "executeCode"] ), - execute = executeTool, + execute = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function) -> begin + tool_name = get(args, "name", "")::String + tool_label = get(args, "label", tool_name)::String + tool_description = get(args, "description", "")::String + tool_schema = get(args, "inputSchema", Dict{String,Any}())::Dict{String,Any} + execute_code = get(args, "executeCode", "")::String + validate_code = get(args, "validateCode", nothing)::Union{String,Nothing} + prepare_code = get(args, "prepareCode", nothing)::Union{String,Nothing} + parallel = get(args, "parallel", false)::Bool + + # Validate tool name + name_err = validateToolName(tool_name) + if name_err !== nothing + return agentToolResult( + [textContent(name_err)], + Dict{Any,Any}(), nothing, false + ) + end + + # Validate required fields + if isempty(tool_name) + return agentToolResult( + [textContent("Missing required field: 'name'")], + Dict{Any,Any}(), nothing, false + ) + end + if isempty(tool_description) + return agentToolResult( + [textContent("Missing required field: 'description'")], + Dict{Any,Any}(), nothing, false + ) + end + if isempty(execute_code) + return agentToolResult( + [textContent("Missing required field: 'executeCode'")], + Dict{Any,Any}(), nothing, false + ) + end + + onPartialResult(Dict("status" => "Generating tool: $tool_name")) + + # Build the tool file path + script_dir = dirname(@__FILE__) + tools_dir = dirname(script_dir) + filepath = joinpath(tools_dir, "$(tool_name).jl") + + # Check for naming conflicts + if isfile(filepath) + return agentToolResult( + [textContent("Tool file already exists: $filepath. Rename the tool or delete the existing file first.")], + Dict{Any,Any}(), nothing, false + ) + end + + onPartialResult(Dict("status" => "Writing file: $(basename(filepath))")) + + # Convert schema Dict to a Julia Dict literal string + schema_literal = dict_to_julia_literal(tool_schema) + + # Build optional validation function + validate_section = if validate_code !== nothing && !isempty(validate_code) + indented = indent_code(validate_code, 4) + "function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}\n$indented\n return nothing\nend\n" + else + "" + end + + # Build optional prepare function + prepare_section = if prepare_code !== nothing && !isempty(prepare_code) + indented = indent_code(prepare_code, 4) + "function prepareArguments(args::Dict{String,Any})::Dict{String,Any}\n$indented\n return args\nend\n" + else + "" + end + + # Indent user's execute code for embedding inside execute function body + indented_exec = indent_code(execute_code, 4) + + # Escape description for Julia string literal + escaped_desc = replace(tool_description, "\\" => "\\\\") + escaped_desc = replace(escaped_desc, "\"" => "\\\"") + + # Build the complete tool file content + parts = String[] + push!(parts, "# Auto-generated tool: $tool_name\n") + push!(parts, "# Generated by writeTool at $(now())\n\n") + if !isempty(validate_section) + push!(parts, validate_section) + push!(parts, "\n") + end + if !isempty(prepare_section) + push!(parts, prepare_section) + push!(parts, "\n") + end + push!(parts, "\n") + push!(parts, "# Execute function\n") + push!(parts, "function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult\n") + push!(parts, "$indented_exec\n") + push!(parts, "end\n\n") + push!(parts, "# Tool definition\n") + push!(parts, "function getTool()::agentTool\n") + push!(parts, " return agentTool(\n") + push!(parts, " name = \"$(tool_name)\",\n") + push!(parts, " label = \"$(tool_label)\",\n") + push!(parts, " description = \"$(escaped_desc)\",\n") + push!(parts, " inputSchema = $schema_literal,\n") + push!(parts, " execute = executeTool,\n") + if validate_code !== nothing && !isempty(validate_code) + push!(parts, " validateRequiredArgs = validateRequiredArgs,\n") + else + push!(parts, " validateRequiredArgs = nothing,\n") + end + if prepare_code !== nothing && !isempty(prepare_code) + push!(parts, " prepareArguments = prepareArguments,\n") + else + push!(parts, " prepareArguments = nothing,\n") + end + push!(parts, " parallelToolExecute = $parallel\n") + push!(parts, " )\n") + push!(parts, "end\n") + + tool_code = join(parts) + + # Write the file — tool is loaded on next agent restart via loadTools() + write(filepath, tool_code) + + onPartialResult(Dict("status" => "Done")) + + return agentToolResult( + [textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools() picks it up, then call listTools to verify.")], + Dict{Any,Any}( + "file" => filepath, + "name" => tool_name, + "label" => tool_label, + "description" => tool_description, + ), + nothing, false + ) + end, prepareArguments = nothing, validateRequiredArgs = nothing, parallelToolExecute = false diff --git a/test/loadToolTest.jl b/test/loadToolTest.jl index d228efe..a6a974e 100644 --- a/test/loadToolTest.jl +++ b/test/loadToolTest.jl @@ -3,9 +3,8 @@ using YiemAgent using YiemAgent.toolRegistry using YiemAgent.type -# ------------------------------------------------------------------ # -# loadTools() unit tests # -# ------------------------------------------------------------------ # +# Path to the real tools directory +TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @testset "loadTools" begin @@ -16,138 +15,99 @@ using YiemAgent.type # ------------------------------------------------------------------ # # 2. loadTools throws if a .jl file does not define getTool() # - # Must run before any other loadTools call (getTool persists in # - # module scope after include()). # + # Must run BEFORE any other loadTools call (getTool binding # + # persists in module scope after include()). # # ------------------------------------------------------------------ # bad_dir = mktempdir() write(joinpath(bad_dir, "noTool.jl"), "x = 42\n") @test_throws ArgumentError loadTools(bad_dir) # ------------------------------------------------------------------ # - # 3. loadTools loads tool files that define getTool() # + # 3. loadTools loads actual tool files from src/tools/ # # ------------------------------------------------------------------ # - tmpdir = mktempdir() - - # Create a valid tool file (must use bare type names — include() places file in toolRegistry scope) - valid_tool_echo = """ -function getTool()::agentTool - return agentTool( - name = "testEcho", - label = "Echo Test", - description = "Echoes the input argument", - inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict("message" => Dict("type" => "string")), - "required" => Any["message"] - ), - execute = (toolCallId, args, signal, onPartialResult) -> begin - return agentToolResult( - [textContent("echo: " * string(args["message"]))], - Dict{Any,Any}(), nothing, false - ) - end, - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end -""" - write(joinpath(tmpdir, "getEcho.jl"), valid_tool_echo) - - loaded = loadTools(tmpdir) + loaded = loadTools(TOOLS_DIR) @test !isempty(loaded) - @test length(loaded) >= 1 + @test length(loaded) == 3 names = [t.name for t in loaded] - @test "testEcho" in names - - # Check agentTool fields - echo_tool = filter(t -> t.name == "testEcho", loaded) - @test !isempty(echo_tool) - @test echo_tool[1].label == "Echo Test" - @test echo_tool[1].description == "Echoes the input argument" - @test echo_tool[1].parallelToolExecute == false - @test echo_tool[1].execute !== nothing + @test "getTime" in names + @test "getWeather" in names + @test "writeTool" in names # ------------------------------------------------------------------ # - # 4. loadTools returns tools sorted alphabetically # + # 4. loadTools returns tools sorted alphabetically by filename # + # (getTime.jl < getWeather.jl < writeTool.jl) # + # because 'T' < 'W' in ASCII # # ------------------------------------------------------------------ # - sorted_dir = mktempdir() - - tool_a = """ -function getTool()::agentTool - return agentTool( - name = "alphaTool", - label = "Alpha Tool", - description = "First tool", - inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]), - execute = (toolCallId, args, signal, onPartialResult) -> - agentToolResult([textContent("alpha")], Dict{Any,Any}(), nothing, false), - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end -""" - - tool_m = """ -function getTool()::agentTool - return agentTool( - name = "midTool", - label = "Mid Tool", - description = "Middle tool", - inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]), - execute = (toolCallId, args, signal, onPartialResult) -> - agentToolResult([textContent("mid")], Dict{Any,Any}(), nothing, false), - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end -""" - - tool_z = """ -function getTool()::agentTool - return agentTool( - name = "zuluTool", - label = "Zulu Tool", - description = "Last tool", - inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]), - execute = (toolCallId, args, signal, onPartialResult) -> - agentToolResult([textContent("zulu")], Dict{Any,Any}(), nothing, false), - prepareArguments = nothing, - validateRequiredArgs = nothing, - parallelToolExecute = false - ) -end -""" - - write(joinpath(sorted_dir, "zTool.jl"), tool_z) - write(joinpath(sorted_dir, "aTool.jl"), tool_a) - write(joinpath(sorted_dir, "mTool.jl"), tool_m) - - loaded_sorted = loadTools(sorted_dir) - # loadTools returns only tools loaded from the directory, in file-sorted order - @test length(loaded_sorted) == 3 - @test loaded_sorted[1].name == "alphaTool" - @test loaded_sorted[2].name == "midTool" - @test loaded_sorted[3].name == "zuluTool" + @test loaded[1].name == "getTime" + @test loaded[2].name == "getWeather" + @test loaded[3].name == "writeTool" # ------------------------------------------------------------------ # - # 5. getTools returns a deep copy (mutations don't affect registry) # + # 5. Verify loaded tool fields are correct # + # ------------------------------------------------------------------ # + # getTime + time_tool = loaded[1] + @test time_tool.name == "getTime" + @test time_tool.label == "Time Lookup" + @test time_tool.validateRequiredArgs !== nothing + @test time_tool.parallelToolExecute == false + @test time_tool.inputSchema["required"] == Any[] + + # getWeather + weather = loaded[2] + @test weather.name == "getWeather" + @test weather.label == "Weather Lookup" + @test weather.execute !== nothing + @test weather.parallelToolExecute == false + @test weather.inputSchema["required"] == ["city"] + + # writeTool + wt = loaded[3] + @test wt.name == "writeTool" + @test wt.label == "Create Tool" + @test wt.execute !== nothing + @test "name" in wt.inputSchema["required"] + @test "executeCode" in wt.inputSchema["required"] + + # ------------------------------------------------------------------ # + # 6. Tool execution returns valid results # + # ------------------------------------------------------------------ # + sig = nothing + op = x -> x # no-op partial result callback + + # execute getTime + result_t = time_tool.execute("call-1", Dict{String,Any}("city" => "Tokyo"), sig, op) + @test result_t isa agentToolResult + @test result_t.content[1] isa textContent + @test occursin("Tokyo", result_t.content[1].text) + + # execute getTime with timezone + result_tz = time_tool.execute("call-2", Dict{String,Any}("timezone" => "America/New_York"), sig, op) + @test result_tz isa agentToolResult + @test occursin("America/New_York", result_tz.content[1].text) + + # execute getWeather + result_w = weather.execute("call-3", Dict{String,Any}("city" => "Bangkok"), sig, op) + @test result_w isa agentToolResult + @test result_w.content[1] isa textContent + @test occursin("Bangkok", result_w.content[1].text) + + # execute getWeather with units + result_w2 = weather.execute("call-4", Dict{String,Any}("city" => "London", "units" => "fahrenheit"), sig, op) + @test occursin("72°F", result_w2.content[1].text) + + # ------------------------------------------------------------------ # + # 7. getTools / registerTool / clearTools # # ------------------------------------------------------------------ # registry_tools = getTools() @test !isempty(registry_tools) - orig_count = length(registry_tools) + @test any(t -> t.name == "getTime", registry_tools) + @test any(t -> t.name == "getWeather", registry_tools) - # Clear and add a new tool via registerTool clearTools() - registry_after_clear = getTools() - @test isempty(registry_after_clear) + @test isempty(getTools()) - # ------------------------------------------------------------------ # - # 6. registerTool adds to global registry # - # ------------------------------------------------------------------ # test_tool = agentTool( name = "manualTool", label = "Manual Tool", @@ -163,13 +123,10 @@ end reg = getTools() @test any(t -> t.name == "manualTool", reg) @test count(t -> t.name == "manualTool", reg) == 1 - - # parallelToolExecute flag - manual_entry = filter(t -> t.name == "manualTool", reg) - @test manual_entry[1].parallelToolExecute == true + @test reg[1].parallelToolExecute == true # ------------------------------------------------------------------ # - # 7. getTools returns deep copy # + # 8. getTools returns deep copy (mutations don't affect registry) # # ------------------------------------------------------------------ # copy1 = getTools() copy2 = getTools()