From d7adfaffa8cfd3c605d6667ad9b050430b490a28 Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 23 Jul 2026 21:14:45 +0700 Subject: [PATCH 1/7] update --- src/interface.jl | 104 ++++++++++++++++++++++++++++++++++++--------- src/llmfunction.jl | 22 +++++++++- src/type.jl | 7 +-- 3 files changed, 105 insertions(+), 28 deletions(-) diff --git a/src/interface.jl b/src/interface.jl index 78f6ad9..e1a333a 100644 --- a/src/interface.jl +++ b/src/interface.jl @@ -122,10 +122,70 @@ function decisionMaker(a::T; recentevents::Integer=20, maxattempt=3 errornote = "N/A" response = nothing # placeholder for show when error msg show up + """ + { + "model": "your-model.gguf", + "messages": [ ... ], + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "agent_action", + "strict": true, + "schema": { + "type": "object", + "properties": { + "think": { + "type": "string", + "description": "Your step-by-step reasoning process. Explain why you are choosing this action." + }, + "action_name": { + "type": "string", + "enum": ["search_web", "get_weather", "calculate_math"], + "description": "The exact name of the tool to execute." + }, + "action_input": { + "type": "object", + "properties": { + "query": { "type": ["string", "null"], "description": "For search_web" }, + "location": { "type": ["string", "null"], "description": "For get_weather" }, + "equation": { "type": ["string", "null"], "description": "For calculate_math" } + }, + "required": ["query", "location", "equation"], + "additionalProperties": false + } + }, + "required": ["think", "action_name", "action_input"], + "additionalProperties": false + } + } + } + } + """ + + # strict output format + json_schema = Dict( + "type"=> "json_schema", + "json_schema"=> Dict( + "name"=> "user_profile", + "strict"=> true, + "schema"=> Dict( + "type"=> "object", + "properties"=> Dict( + "plan"=> Dict("type"=> "string"), + "action_name"=> Dict("type"=> "string"), + "action_input"=> Dict("type"=> "string"), + ), + "required"=> ["plan", "action_name", "action_input"], + "additionalProperties"=> false + ) + ) + ) + msg = Dict( - "model" => "gemma-4-E4B-it-UD-Q4_K_XL", - "messages" => a.chathistory, - "temperature" => 0.7 + "model"=> "gemma-4-E4B-it-UD-Q4_K_XL", + "messages"=> a.chathistory, + "temperature"=> 0.7, + "response_format"=> json_schema, ) for attempt in 1:maxattempt @@ -138,25 +198,27 @@ function decisionMaker(a::T; recentevents::Integer=20, maxattempt=3 response = replace(response, '$' => "USD") end - responsedict = nothing - try - responsedict = Serde.parse_yaml(response) - catch e - println("\nERROR YiemAgent decisionMaker() Error: $e --(not qualify response)-> $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end + # responsedict = nothing + # try + # responsedict = Serde.parse_yaml(response) + # catch e + # println("\nERROR YiemAgent decisionMaker() Error: $e --(not qualify response)-> $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") + # continue + # end - # check whether all answer's key points are in responsedict - println("\n---") - println(responsedict) - println("---\n") - ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys) + # # check whether all answer's key points are in responsedict + # println("\n---") + # println(responsedict) + # println("---\n") + # ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys) - if !ispass - errornote = errormsg - println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)-> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end + # if !ispass + # errornote = errormsg + # println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)-> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") + # continue + # end + + responsedict = JSON.parse(response) if responsedict["action_input"] == "CHAT_BOX" && occursin("similar", responsedict["action_input"]) @@ -564,7 +626,7 @@ function think(a::T)::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, end @info "YiemAgent think() end " @__LINE__ - @show thoughtdict + # @show thoughtdict println("---\n") return (thoughtdict=thoughtdict, result_raw=result_raw) end diff --git a/src/llmfunction.jl b/src/llmfunction.jl index 61d699d..4f0cfc9 100644 --- a/src/llmfunction.jl +++ b/src/llmfunction.jl @@ -637,7 +637,8 @@ function predefined_wine_search_sql(a::T, searchterm::String, # your responsibility includes Fulfill the objective. - # you should only respond in YAML format as described below + # you should only respond in JSON format as described below + { table_name_1: column_name_1: operator: "=" @@ -654,6 +655,7 @@ function predefined_wine_search_sql(a::T, searchterm::String, operator: "=" value: "..." ... + } # here are some example @@ -700,6 +702,24 @@ function predefined_wine_search_sql(a::T, searchterm::String, """ input = context * searchterm + json_schema = Dict( + "type"=> "json_schema", + "json_schema"=> Dict( + "name"=> "user_profile", + "strict"=> true, + "schema"=> Dict( + "type"=> "object", + "properties"=> Dict( + "plan"=> Dict("type"=> "string"), + "action_name"=> Dict("type"=> "string"), + "action_input"=> Dict("type"=> "string"), + ), + "required"=> ["plan", "action_name", "action_input"], + "additionalProperties"=> false + ) + ) + ) + msg = Dict( "model" => "gemma-4-E4B-it-UD-Q4_K_XL", "messages" => [ diff --git a/src/type.jl b/src/type.jl index 452ecb6..fccda67 100644 --- a/src/type.jl +++ b/src/type.jl @@ -200,17 +200,12 @@ function sommelier( - Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store. - Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store. - # you should then respond to the user with interleaving plan, action_name, action_input + # you should then respond to the user with interleaving plan, action_name, action_input in JSON format 1) "plan", Based on the current situation, state a complete action plan to complete the task and rationale. Be specific. 2) "action_name", (Typically corresponds to the execution of the first step in your plan) Can be one of the available_actions name 3) "action_input", The input to the action you are about to perform according to your plan. After the action is executed you gets "action_result". It is the output from the action you selected. - # you should only respond in YAML format as described below - plan: "..." - action_name: "..." - action_input: "..." - # available actions "CHAT_BOX", which you can use to talk with the user. The input is dialogue you want to chat with the user according to your plan. "SEARCH_WINE_DATABASE", allows you to search information about wines you want in your inventory's database. The input is strictly supported search term including: retailer_name, wine price, winery, name, vintage, region, country, type of wine, grape varietal, tasting notes, occasion, food pairing, intensity, tannin, sweetness, and acidity. -- 2.52.0 From 7ec3edfd7701f6dccbd0d51272fd374a453ecafd Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 24 Jul 2026 08:31:36 +0700 Subject: [PATCH 2/7] update --- etc.jl | 210 ++----------------------- src/interface.jl | 4 +- src/llmfunction.jl | 376 ++++++++++++++++++++++----------------------- src/type.jl | 1 - 4 files changed, 198 insertions(+), 393 deletions(-) diff --git a/etc.jl b/etc.jl index 1cc309e..a35aae6 100644 --- a/etc.jl +++ b/etc.jl @@ -1,200 +1,10 @@ -using HTTP, JSON, URIs, Random, PrettyPrinting, UUIDs, Dates, DataFrames, DataStructures -using GeneralUtils, SQLLLM, YiemAgent - -config = JSON.parsefile("./appconfig.json") -host_url, _port = split(config["externalservice"]["sommpanion_db"]["url"], ':') -port = parse(Int, _port) -dbname = "winedb" -user = config["externalservice"]["sommpanion_db"]["user"] -password = config["externalservice"]["sommpanion_db"]["password"] -pg_conn_str = "host=$host_url port=$port dbname=$dbname user=$user password=$password" - - function execute_sql_winedb(sql::T) where {T<:AbstractString} - host_url, _port = split(config["externalservice"]["sommpanion_db"]["url"], ':') - port = parse(Int, _port) - dbname = "winedb" - user = config["externalservice"]["sommpanion_db"]["user"] - password = config["externalservice"]["sommpanion_db"]["password"] - db_connection = LibPQ.Connection("host=$host_url port=$port dbname=$dbname user=$user password=$password") - result = nothing - try - result = LibPQ.execute(db_connection, sql) - catch e - LibPQ.close(db_connection) - end - - LibPQ.close(db_connection) - return result - end - - - -sql = -""" -SELECT T1.winery, T1.wine_name, T1.wine_id, T1.vintage, T1.region, T1.country, T1.wine_type, T1.grape, T1.serving_temperature, T1.sweetness, T1.intensity, T1.tannin, T1.acidity, T1.tasting_notes, T2.price, T2.currency, T1.image_url, T3.retailer_name, T3.retailer_id FROM "wine" AS T1 JOIN "retailer_wine" AS T2 ON T1.wine_id = T2.wine_id JOIN "retailer" AS T3 ON T2.retailer_id = T3.retailer_id WHERE T1.wine_name = 'Montrachet Grand Cru' AND T1.winery = 'Domaine Jacques Prieur' AND T3.retailer_name = 'Yiem Wines Ltd' AND T3.retailer_id = 'f54eab6b-7650-4448-b009-c53f3efbcc3b'; -""" - -textresult, sql_result_raw, _, _ = YiemAgent.SQLexecution(execute_sql_winedb, sql) -result_vec = GeneralUtils.dfToVectorDict(sql_result_raw) - -for d in result_vec - wine_name = d["wine_name"] - image_url_json_str = d["image_url"] - image_url_json_obj = JSON.parse(image_url_json) - base_url = "http://192.168.88.106:8080/" - image_base64 = - if haskey(image_url_json_obj, "bottle") - url = base_url * image_url_json_obj["bottle"] - image_data = HTTP.get(url) # vector{int} data - image_base64_string = base64encode(image_data) - else - nothing - end - d["image"] = image_base64 -end - - - - - - - - - - -using LibPQ -using Tables - -""" - update_car_regions_one_by_one(conn::LibPQ.Connection, target_word::String) - -Iterates through all rows in the 'car' table where the region is "German", -and updates them one-by-one to the `target_word`. -""" -function update_car_regions_one_by_one(pg_conn_str::String, replace_word::String , target_word::String) - conn = LibPQ.Connection(pg_conn_str) - # 1. Fetch the target rows. Assumes 'id' is the primary key. - # We select the ID to target rows individually during the update step. - select_query = "SELECT id FROM car WHERE region = '$replace_word';" - - result = execute(conn, select_query) - rows = Tables.rows(result) - - # 2. Prepare the update statement for execution reuse - # Using explicit types for parameter placeholders ($1, $2) - update_query = "UPDATE car SET region = \$1 WHERE id = \$2;" - - println("Starting one-by-one update...") - updated_count = 0 - - # 3. Iterate through rows one-by-one - for row in rows - # LibPQ row values are accessed via properties or column names - row_id = row.id - - # Execute the parameterized statement safely - execute(conn, update_query, [target_word, row_id]) - updated_count += 1 - end - - println("Successfully updated \$updated_count rows.") - return updated_count -end - - - - - - - - - - - -function generate_wine_retail_sql(conditions::Dict{String, Any})::String - # 1. Base SQL structure - base_query = """ -SELECT - w.winery, - w.wine_name, - w.wine_id, - w.vintage, - w.region, - w.country, - w.wine_type, - w.grape, - w.serving_temperature, - w.sweetness, - w.intensity, - w.tannin, - w.acidity, - w.tasting_notes, - rw.price, - rw.currency, - w.image_url, - NULL AS retailer_name, - rw.retailer_id -FROM wine AS w -JOIN retailer_wine AS rw - ON w.wine_id = rw.wine_id -""" - - # 2. Dynamic WHERE Clause Builder - where_clauses = String[] - - # Iterate over each table condition provided - for (table_name, table_conditions) in conditions - - # Determine table alias - alias = if table_name == "wine" - "w" - elseif table_name == "retailer_wine" - "rw" - else - continue # Skip unsupported tables - end - - # Process condition dictionaries - if isa(table_conditions, Dict) && !isempty(table_conditions) - - for (column_name, filter_details) in table_conditions - - if isa(filter_details, Dict) && haskey(filter_details, "operator") - op = filter_details["operator"] - raw_val = filter_details["value"] - - # --- Value Type Handling --- - # Use tryparse instead of try/catch for cleaner, faster parsing - final_val = raw_val - - if op in ("=", "<", ">", "<=", ">=") - str_val = string(raw_val) - num_val = tryparse(Float64, str_val) - - if !isnothing(num_val) - final_val = isinteger(num_val) ? round(Int, num_val) : num_val - end - end - - # --- SQL Formatting --- - if isa(final_val, Number) - clause = "$(alias).$(column_name) $(op) $(final_val)" - else - # Escape single quotes within string values - escaped_val = replace(string(final_val), "'" => "''") - clause = "$(alias).$(column_name) $(op) '$(escaped_val)'" - end - - push!(where_clauses, clause) - end - end - end - end - - # 3. Assemble Final Query - where_sql = isempty(where_clauses) ? "" : "WHERE " * join(where_clauses, " AND ") - - return string(base_query, where_sql, ";") -end - - +# check if this column has vector embedding. if there is one, seach vector version instead + column_name_embedding = column_name * "_embedding" + if occursin(column_name_embedding, tables_schema[column_name_embedding]) + vector_column = Dict( + "table_name"=> table_name, + "column_name"=> column_name_embedding, + "operator"=> "vector_similarity", + "value"=> column_obj["value"] + ) + end \ No newline at end of file diff --git a/src/interface.jl b/src/interface.jl index e1a333a..4750fca 100644 --- a/src/interface.jl +++ b/src/interface.jl @@ -163,7 +163,7 @@ function decisionMaker(a::T; recentevents::Integer=20, maxattempt=3 """ # strict output format - json_schema = Dict( + response_format = Dict( "type"=> "json_schema", "json_schema"=> Dict( "name"=> "user_profile", @@ -185,7 +185,7 @@ function decisionMaker(a::T; recentevents::Integer=20, maxattempt=3 "model"=> "gemma-4-E4B-it-UD-Q4_K_XL", "messages"=> a.chathistory, "temperature"=> 0.7, - "response_format"=> json_schema, + "response_format"=> response_format, ) for attempt in 1:maxattempt diff --git a/src/llmfunction.jl b/src/llmfunction.jl index 4f0cfc9..2d7d41a 100644 --- a/src/llmfunction.jl +++ b/src/llmfunction.jl @@ -637,45 +637,47 @@ function predefined_wine_search_sql(a::T, searchterm::String, # your responsibility includes Fulfill the objective. - # you should only respond in JSON format as described below - { - table_name_1: - column_name_1: - operator: "=" - value: "..." - column_name_2: - operator: "=" - value: "..." - ... - table_name_2: - column_name_1: - operator: "=" - value: "..." - column_name_2: - operator: "=" - value: "..." - ... - } + # You must output your response as a JSON object containing a single key: "extracted_info". + The "extracted_info" key must contain an array of objects. Each object must contain: + 1) "table_name": The name of the table. + 2) "column_name": The specific column being filtered. + 3) "operator": The comparison operator (e.g., "=", ">", "LIKE"). + 4) "value": The value to compare against. + + If the user does not specify any filters, return an empty array for "extracted_info": {"extracted_info": []}. # here are some example 4-wheel drive car with red color that will give me fast and furious emotion. No more than 7000 USD - car_info: # table_name - drive_type: # column_name - operator: "=" # operator is not "N/A" because drive_type column store quantitative value - value: "4-wheel" # column_value - color: - operator: "=" # operator is not "N/A" because color column store quantitative value - value: "red" - drive_feeling: - operator: "N/A" # operator is "N/A" because drive_feeling column store qualitative value - value: "fast and furious" - price_list: - price: - operator: "<" # operator is not "N/A" because drive_type column store quantitative value - value: "7000" + { + "extracted_info": [ + { + "table_name": "car_info", + "column_name": "drive_type", + "operator": "=", + "value": "4-wheel" + }, + { + "table_name": "car_info", + "column_name": "color", + "operator": "=", + "value": "red" + }, + { + "table_name": "car_info", + "column_name": "drive_feeling", + "operator": "ILIKE", + "value": "fast and furious" + }, + { + "table_name": "price_list", + "column_name": "price", + "operator": "<", + "value": "7000" + } + } """ @@ -702,23 +704,47 @@ function predefined_wine_search_sql(a::T, searchterm::String, """ input = context * searchterm - json_schema = Dict( - "type"=> "json_schema", - "json_schema"=> Dict( - "name"=> "user_profile", - "strict"=> true, - "schema"=> Dict( - "type"=> "object", - "properties"=> Dict( - "plan"=> Dict("type"=> "string"), - "action_name"=> Dict("type"=> "string"), - "action_input"=> Dict("type"=> "string"), - ), - "required"=> ["plan", "action_name", "action_input"], - "additionalProperties"=> false + response_format = Dict( + "type" => "json_schema", + "json_schema" => Dict( + "name" => "extracted_conditions", + "strict" => true, + "schema" => Dict( + "type" => "object", + "properties" => Dict( + "extracted_info" => Dict( + "type" => "array", + "items" => Dict( + "type" => "object", + "properties" => Dict( + "table_name" => Dict( + "type" => "string", + "description" => "The name of the database table." + ), + "column_name" => Dict( + "type" => "string", + "description" => "The name of the column to filter on." + ), + "operator" => Dict( + "type" => "string", + "enum" => ["=", "!=", ">", "<", ">=", "<=", "LIKE", "IN", "IS NULL", "IS NOT NULL"], + "description" => "The SQL comparison operator." + ), + "value" => Dict( + "type" => ["string", "null"], + "description" => "The value to compare against. Use null for IS NULL/IS NOT NULL." + ) + ), + "required" => ["table_name", "column_name", "operator", "value"], + "additionalProperties" => false + ) + ) + ), + "required" => ["extracted_info"], + "additionalProperties" => false ) - ) ) +) msg = Dict( "model" => "gemma-4-E4B-it-UD-Q4_K_XL", @@ -736,85 +762,63 @@ function predefined_wine_search_sql(a::T, searchterm::String, ] ), ], - "temperature" => 0.7 + "temperature" => 0.7, + "response_format"=> response_format, ) for attempt in 1:maxattempt response = a.context.text2textInstructLLM("random_id", msg) - - responsedict = nothing - try - responsedict = Serde.parse_yaml(response) - catch e - println("\nERROR YiemAgent predefined_wine_search_sql() Error: $e --(not qualify response)-> $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end + responsedict = JSON.parse(response) + # responsedict = nothing + # try + # responsedict = Serde.parse_yaml(response) + # catch e + # println("\nERROR YiemAgent predefined_wine_search_sql() Error: $e --(not qualify response)-> $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") + # continue + # end # println("\n ", table_schema) println("\n ", responsedict) - @info "before BM25 " @__LINE__ + @info "before BM25 " @__LINE__ - """ - responsedict = Dict( - "wine" => Dict( - "tasting_notes" => Dict( - "operator" => "N/A", - "value" => "casual dinner" - ), - "wine_type" => Dict( - "operator" => "=", - "value" => "red" - ) - ), - "retailer_wine" => Dict( - "currency" => Dict( - "operator" => "=", "value" => "USD" - ), - "price" => Dict( - "operator" => "<", "value" => "1000" - ) - ) - ) - """ - - for (table_name, table_info_dict) in responsedict - for (column_name, v) in table_info_dict - bucket = classify_column(a.context.pg_conn_str, table_name, column_name) - - if bucket == "fuzzy_correction" - words_catalog = GeneralUtils.harvest_entity_catalog(a.context.pg_conn_str, table_name, column_name) - resolved_word = GeneralUtils.resolve_entity(v["value"], words_catalog; threshold=0.9) - table_info_dict[column_name]["value"] = resolved_word - end + #WORKING to ensure user input is correct + for entry in responsedict["extracted_info"] + table_name = entry["table_name"]::String + column_name = entry["column_name"]::String + + bucket = classify_column(a.context.pg_conn_str, table_name, column_name) + + if bucket == "fuzzy_correction" + words_catalog = GeneralUtils.harvest_entity_catalog(a.context.pg_conn_str, table_name, column_name) + resolved_word = GeneralUtils.resolve_entity(entry["value"], words_catalog; threshold=0.9) + entry["value"] = resolved_word end end - # filter for column that will be used for hard condition (SQL where clause) - # column with "N/A" operator will be used in vector search + # column with non-standard operator will be used in vector search vector_search_words = "" - for (table_name, table_dict) in responsedict - for (column_name, column_dict) in table_dict - if column_dict["operator"] ∉ ["=","<>","!=",">","<",">=","<=","!<","!>","<=>"] - vector_search_words = vector_search_words * column_dict["value"] * ", " - delete!(table_dict, column_name) + hard_operators = ["=","<>","!=",">","<",">=","<=","!<","!>","<=>"] - # remove table from responsedict if there is no column to used - if length(responsedict[table_name]) == 0 - delete!(responsedict, table_name) - end - end + # Build new list of hard condition entries + hard_conditions = JSON.Object{String, Any}[] + for entry in responsedict["extracted_info"] + if entry["operator"] ∈ hard_operators + push!(hard_conditions, entry) + else + vector_search_words = vector_search_words * entry["value"] * ", " end end + responsedict = hard_conditions println("") - pprintln(responsedict) + @show responsedict @info "predefined_wine_search_sql() " @__LINE__ - #WORKING do vector searched + #WORKING do vector search println("") @show vector_search_words - + # error(9999) sql = predefined_wine_search_sql(responsedict) return sql @@ -822,6 +826,83 @@ function predefined_wine_search_sql(a::T, searchterm::String, error("SQLLLM DecisionMaker() failed to generate a thought \n", response) end +function predefined_wine_search_sql(conditions::Vector{JSON.Object{String, Any}})::String + # 1. Base SQL structure + base_query = +""" +SELECT + w.winery, + w.wine_name, + w.wine_id, + w.vintage, + w.region, + w.country, + w.wine_type, + w.grape, + w.serving_temperature, + w.sweetness, + w.intensity, + w.tannin, + w.acidity, + w.tasting_notes, + rw.price, + rw.currency, + w.image_url, + r.retailer_name, + rw.retailer_id +FROM wine AS w +JOIN retailer_wine AS rw ON w.wine_id = rw.wine_id +JOIN retailer AS r ON rw.retailer_id = r.retailer_id +""" + + # 2. Dynamic WHERE Clause Builder + where_clauses = String[] + + # Iterate over each condition object in the array + for cond in conditions + table_name = String(cond["table_name"]) + column_name = String(cond["column_name"]) + op = String(cond["operator"]) + raw_val = cond["value"] + + # Determine table alias + alias = if table_name == "wine" + "w" + elseif table_name == "retailer_wine" + "rw" + else + continue + end + + # --- Value Type Handling --- + final_val = raw_val + + if op in ("=", "<", ">", "<=", ">=") + str_val = string(raw_val) + num_val = tryparse(Float64, str_val) + + if !isnothing(num_val) + final_val = isinteger(num_val) ? round(Int, num_val) : num_val + end + end + + # --- SQL Formatting --- + if isa(final_val, Number) + clause = "$(alias).$(column_name) $(op) $(final_val)" + else + escaped_val = replace(string(final_val), "'" => "''") + clause = "$(alias).$(column_name) $(op) '$(escaped_val)'" + end + + push!(where_clauses, clause) + end + + # 3. Assemble Final Query + where_sql = isempty(where_clauses) ? "" : "WHERE " * join(where_clauses, " AND ") + + return string(base_query, where_sql, ";") +end + function SQLexecution(executeSQL::Function, sql::T )::NamedTuple where {T<:AbstractString} @@ -1306,92 +1387,7 @@ function extractWineAttributes_2(a::T1, input::T2)::String where {T1<:agent, T2< error("extractWineAttributes_2() failed to get a response") end -function predefined_wine_search_sql(conditions::Dict{String, Any})::String - # 1. Base SQL structure - base_query = -""" -SELECT - w.winery, - w.wine_name, - w.wine_id, - w.vintage, - w.region, - w.country, - w.wine_type, - w.grape, - w.serving_temperature, - w.sweetness, - w.intensity, - w.tannin, - w.acidity, - w.tasting_notes, - rw.price, - rw.currency, - w.image_url, - r.retailer_name, - rw.retailer_id -FROM wine AS w -JOIN retailer_wine AS rw ON w.wine_id = rw.wine_id -JOIN retailer AS r ON rw.retailer_id = r.retailer_id -""" - # 2. Dynamic WHERE Clause Builder - where_clauses = String[] - - # Iterate over each table condition provided - for (table_name, table_conditions) in conditions - - # Determine table alias - alias = if table_name == "wine" - "w" - elseif table_name == "retailer_wine" - "rw" - else - continue # Skip unsupported tables - end - - # Process condition dictionaries - if isa(table_conditions, Dict) && !isempty(table_conditions) - - for (column_name, filter_details) in table_conditions - - if isa(filter_details, Dict) && haskey(filter_details, "operator") - op = filter_details["operator"] - raw_val = filter_details["value"] - - # --- Value Type Handling --- - # Use tryparse instead of try/catch for cleaner, faster parsing - final_val = raw_val - - if op in ("=", "<", ">", "<=", ">=") - str_val = string(raw_val) - num_val = tryparse(Float64, str_val) - - if !isnothing(num_val) - final_val = isinteger(num_val) ? round(Int, num_val) : num_val - end - end - - # --- SQL Formatting --- - if isa(final_val, Number) - clause = "$(alias).$(column_name) $(op) $(final_val)" - else - # Escape single quotes within string values - escaped_val = replace(string(final_val), "'" => "''") - clause = "$(alias).$(column_name) $(op) '$(escaped_val)'" - end - - push!(where_clauses, clause) - end - end - end - end - - # 3. Assemble Final Query - where_sql = isempty(where_clauses) ? "" : "WHERE " * join(where_clauses, " AND ") - - return string(base_query, where_sql, ";") -end diff --git a/src/type.jl b/src/type.jl index fccda67..f960b78 100644 --- a/src/type.jl +++ b/src/type.jl @@ -164,7 +164,6 @@ function sommelier( - You can only recommend wines that are currently in our inventory - Before searching the database for wine, ensure you have at least the following information: 1) budget, 2) wine type, and 3) occasion. Additional details are always helpful. If the user is unsure, provide relevant information and gather insights to make reasonable inferences. - Ask the user one question at a time. - - Do not ask the user about wine's flavor e.g. floral, citrusy, nutty or some thing similar as these terms cannot be used to search the database. - Once the user has selected their wine, if you haven't already, ask the user whether they need any further assistance. Do not offer any additional services. - Only end the conversation when the user explicitly intends to do so. When ending, ensure a polite farewell and an invitation to return in the future. - Spicy foods should be paired only with light red wines. -- 2.52.0 From caed2a82d667d18cbb263fc733810756ca8fb7ed Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 24 Jul 2026 15:51:32 +0700 Subject: [PATCH 3/7] update --- Manifest.toml | 10 +++++----- Project.toml | 2 +- src/llmfunction.jl | 31 ++++++++++++++++--------------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/Manifest.toml b/Manifest.toml index c5957f2..5bfcb9f 100644 --- a/Manifest.toml +++ b/Manifest.toml @@ -2,7 +2,7 @@ julia_version = "1.12.6" manifest_format = "2.0" -project_hash = "dc7878808bbc4637a12e709dd495979a784824a5" +project_hash = "fec141f7ff95c7150e820def295f9929445168b6" [[deps.Accessors]] deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"] @@ -300,11 +300,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 = "a75a088ee8e5faf10f554ca00748e0e6ca58d1ca" +git-tree-sha1 = "596da266c851d00fbabcf33660419aeffb820fcb" repo-rev = "main" repo-url = "https://git.yiem.cc/ton/GeneralUtils" uuid = "c6c72f09-b708-4ac8-ac7c-2084d70108fe" -version = "0.5.1" +version = "0.5.7" [[deps.Graphs]] deps = ["ArnoldiMethod", "DataStructures", "Inflate", "LinearAlgebra", "Random", "SimpleTraits", "SparseArrays", "Statistics"] @@ -1089,10 +1089,10 @@ uuid = "ddb6d928-2868-570f-bddf-ab3f9cf99eb6" version = "0.4.16" [[deps.YiemAgent]] -deps = ["Base64", "CSV", "DataFrames", "DataStructures", "Dates", "GeneralUtils", "HTTP", "JSON", "LLMMCTS", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "SQLLLM", "Serialization", "URIs", "UUIDs"] +deps = ["Base64", "CSV", "DataFrames", "DataStructures", "Dates", "HTTP", "JSON", "LLMMCTS", "LibPQ", "NATS", "PrettyPrinting", "Random", "Revise", "SQLLLM", "Serde", "Serialization", "URIs", "UUIDs"] path = "." uuid = "e012c34b-7f78-48e0-971c-7abb83b6f0a2" -version = "0.7.2" +version = "0.7.4" [[deps.Zlib_jll]] deps = ["Libdl"] diff --git a/Project.toml b/Project.toml index 63f6161..64d5fbf 100644 --- a/Project.toml +++ b/Project.toml @@ -28,7 +28,7 @@ UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" Base64 = "1.11.0" CSV = "0.10.15" DataFrames = "1.7.0" -GeneralUtils = "0.5.1" +GeneralUtils = "0.5.7" HTTP = "2.4.0" JSON = "1.6.1" LLMMCTS = "0.1.5" diff --git a/src/llmfunction.jl b/src/llmfunction.jl index 2d7d41a..fcaf28c 100644 --- a/src/llmfunction.jl +++ b/src/llmfunction.jl @@ -302,10 +302,16 @@ function search_wine_database!(a::T, thoughtdict::AbstractDict; useSQLLLM::Bool= else # direct query with possible sql instead of SQLLLM. - sql = predefined_wine_search_sql(a, thoughtdict["action_input"]) + hard_conditions, vector_search_words = wine_search_term_classification(a, thoughtdict["action_input"]) + + # do hard filter # sql = generatesql(a, inventoryquery) - println("\nSQL: $sql ", @__FILE__, ":", @__LINE__, " $(Dates.now()) \n") + sql = predefined_wine_search_sql(hard_conditions) + @info "\nsql: $sql, \nvector_search_words: $vector_search_words" textresult, sql_result_df, success, _ = SQLexecution(a.context.executeSQL, sql) + + #WORKING do vector search + GeneralUtils.find_text_vector_similarity(vector_search_words, "wine") items = nothing if sql_result_df !== nothing @@ -619,9 +625,9 @@ julia> thoughtdict = ``` julia> predefined_wine_search_sql(agent, thoughtdict["action_input"]) """ -function predefined_wine_search_sql(a::T, searchterm::String, +function wine_search_term_classification(a::T, searchterm::String, ; maxattempt=10 - )::String where {T<:agent} + ) where {T<:agent} systemmsg = """ @@ -641,7 +647,7 @@ function predefined_wine_search_sql(a::T, searchterm::String, The "extracted_info" key must contain an array of objects. Each object must contain: 1) "table_name": The name of the table. 2) "column_name": The specific column being filtered. - 3) "operator": The comparison operator (e.g., "=", ">", "LIKE"). + 3) "operator": The comparison operator (e.g., "=", ">"). 4) "value": The value to compare against. If the user does not specify any filters, return an empty array for "extracted_info": {"extracted_info": []}. @@ -668,7 +674,7 @@ function predefined_wine_search_sql(a::T, searchterm::String, { "table_name": "car_info", "column_name": "drive_feeling", - "operator": "ILIKE", + "operator": "=", "value": "fast and furious" }, { @@ -781,7 +787,7 @@ function predefined_wine_search_sql(a::T, searchterm::String, println("\n ", responsedict) @info "before BM25 " @__LINE__ - #WORKING to ensure user input is correct + # to ensure user input is correct for entry in responsedict["extracted_info"] table_name = entry["table_name"]::String column_name = entry["column_name"]::String @@ -802,11 +808,12 @@ function predefined_wine_search_sql(a::T, searchterm::String, # Build new list of hard condition entries hard_conditions = JSON.Object{String, Any}[] + vector_search = JSON.Object{String, Any}[] for entry in responsedict["extracted_info"] if entry["operator"] ∈ hard_operators push!(hard_conditions, entry) else - vector_search_words = vector_search_words * entry["value"] * ", " + push!(vector_search, entry) end end responsedict = hard_conditions @@ -814,14 +821,8 @@ function predefined_wine_search_sql(a::T, searchterm::String, println("") @show responsedict @info "predefined_wine_search_sql() " @__LINE__ - - #WORKING do vector search - println("") - @show vector_search_words - # error(9999) - sql = predefined_wine_search_sql(responsedict) - return sql + return (hard_conditions=hard_conditions, vector_search=vector_search) end error("SQLLLM DecisionMaker() failed to generate a thought \n", response) end -- 2.52.0 From f8b3150c1780f9feca8dc11d005e9a6b3754ac06 Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 24 Jul 2026 16:44:49 +0700 Subject: [PATCH 4/7] update --- Manifest.toml | 6 +++--- Project.toml | 2 +- src/llmfunction.jl | 24 ++++++++++++++++++++---- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Manifest.toml b/Manifest.toml index 5bfcb9f..ff24a8c 100644 --- a/Manifest.toml +++ b/Manifest.toml @@ -2,7 +2,7 @@ julia_version = "1.12.6" manifest_format = "2.0" -project_hash = "fec141f7ff95c7150e820def295f9929445168b6" +project_hash = "be97ee6871e6226aa3a499d3dfac9cc08bccdfdc" [[deps.Accessors]] deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"] @@ -300,11 +300,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 = "596da266c851d00fbabcf33660419aeffb820fcb" +git-tree-sha1 = "779dc73098f322590b94dc315a497307a0e053d1" repo-rev = "main" repo-url = "https://git.yiem.cc/ton/GeneralUtils" uuid = "c6c72f09-b708-4ac8-ac7c-2084d70108fe" -version = "0.5.7" +version = "0.5.8" [[deps.Graphs]] deps = ["ArnoldiMethod", "DataStructures", "Inflate", "LinearAlgebra", "Random", "SimpleTraits", "SparseArrays", "Statistics"] diff --git a/Project.toml b/Project.toml index 64d5fbf..2ac1798 100644 --- a/Project.toml +++ b/Project.toml @@ -28,7 +28,7 @@ UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" Base64 = "1.11.0" CSV = "0.10.15" DataFrames = "1.7.0" -GeneralUtils = "0.5.7" +GeneralUtils = "0.5.8" HTTP = "2.4.0" JSON = "1.6.1" LLMMCTS = "0.1.5" diff --git a/src/llmfunction.jl b/src/llmfunction.jl index fcaf28c..8d5cc52 100644 --- a/src/llmfunction.jl +++ b/src/llmfunction.jl @@ -302,16 +302,32 @@ function search_wine_database!(a::T, thoughtdict::AbstractDict; useSQLLLM::Bool= else # direct query with possible sql instead of SQLLLM. - hard_conditions, vector_search_words = wine_search_term_classification(a, thoughtdict["action_input"]) + hard_conditions, vector_search = wine_search_term_classification(a, thoughtdict["action_input"]) # do hard filter # sql = generatesql(a, inventoryquery) sql = predefined_wine_search_sql(hard_conditions) - @info "\nsql: $sql, \nvector_search_words: $vector_search_words" + @info "\nsql: $sql, \nvector_search: $vector_search" textresult, sql_result_df, success, _ = SQLexecution(a.context.executeSQL, sql) - #WORKING do vector search - GeneralUtils.find_text_vector_similarity(vector_search_words, "wine") + # do vector search + vector_search_str = "" + for i in vector_search + vector_search_str = vector_search_str * " " * i["value"] + end + vector_search_str = String(strip(vector_search_str)) + vector_search_str = GeneralUtils.removestring(vector_search_str, ["%"]) + @show vector_search + @show vector_search_str + + #WORKING + GeneralUtils.find_text_vector_similarity(vector_search_str, "wine", "tasting_notes_embedding", + GeneralUtils.execute) + + + + + items = nothing if sql_result_df !== nothing -- 2.52.0 From abfe6f45fb8080fa85357ff6e2af778bded8f078 Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 25 Jul 2026 10:13:02 +0700 Subject: [PATCH 5/7] update --- Manifest.toml | 6 +- Project.toml | 2 +- learn_pi_agent/agent_workflow.md | 473 +++++ learn_pi_agent/architecture.md | 2798 +++++++++++++++++++++++++++ learn_pi_agent/memory_management.md | 624 ++++++ src/llmfunction.jl | 85 +- 6 files changed, 3937 insertions(+), 51 deletions(-) create mode 100644 learn_pi_agent/agent_workflow.md create mode 100644 learn_pi_agent/architecture.md create mode 100644 learn_pi_agent/memory_management.md diff --git a/Manifest.toml b/Manifest.toml index ff24a8c..6fe654f 100644 --- a/Manifest.toml +++ b/Manifest.toml @@ -2,7 +2,7 @@ julia_version = "1.12.6" manifest_format = "2.0" -project_hash = "be97ee6871e6226aa3a499d3dfac9cc08bccdfdc" +project_hash = "1c1379a2cec320abc347f3acb5ee815ba9855aa6" [[deps.Accessors]] deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"] @@ -300,11 +300,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 = "779dc73098f322590b94dc315a497307a0e053d1" +git-tree-sha1 = "93293126d24d3929ef6a5067f347bc28c6582c71" repo-rev = "main" repo-url = "https://git.yiem.cc/ton/GeneralUtils" uuid = "c6c72f09-b708-4ac8-ac7c-2084d70108fe" -version = "0.5.8" +version = "0.5.10" [[deps.Graphs]] deps = ["ArnoldiMethod", "DataStructures", "Inflate", "LinearAlgebra", "Random", "SimpleTraits", "SparseArrays", "Statistics"] diff --git a/Project.toml b/Project.toml index 2ac1798..85b19ae 100644 --- a/Project.toml +++ b/Project.toml @@ -28,7 +28,7 @@ UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" Base64 = "1.11.0" CSV = "0.10.15" DataFrames = "1.7.0" -GeneralUtils = "0.5.8" +GeneralUtils = "0.5.10" HTTP = "2.4.0" JSON = "1.6.1" LLMMCTS = "0.1.5" diff --git a/learn_pi_agent/agent_workflow.md b/learn_pi_agent/agent_workflow.md new file mode 100644 index 0000000..29dd313 --- /dev/null +++ b/learn_pi_agent/agent_workflow.md @@ -0,0 +1,473 @@ +# Agent Workflow: Message Handling and Response Generation + +## Overview + +This document explains the complete step-by-step flow of how the agent processes a user message and generates a response, from the moment a user asks "what product do you have in stock" to when the agent responds with an answer. + +--- + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────────────────┐ +│ USER INPUT │ +│ "what product do you have in stock?" │ +└─────────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────┐ +│ 1. AGENT.PROMPT() ENTRY │ +│ File: packages/agent/src/agent.ts:339 │ +│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ +│ │ • Validate no active run (throws if busy) │ │ +│ │ • normalizePromptInput() converts string to AgentMessage[] │ │ +│ │ • runPromptMessages() launches execution with lifecycle events │ │ +│ └──────────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────┐ +│ 2. AGENT LIFECYCLE INITIALIZATION │ +│ File: packages/agent/src/agent.ts:398-412 │ +│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ +│ │ Events Emitted: │ │ +│ │ • agent_start │ │ +│ │ • turn_start │ │ +│ │ • message_start / message_end (for each prompt message) │ │ +│ │ │ │ +│ │ Context Snapshot Created: │ │ +│ │ • systemPrompt │ │ +│ │ • messages (copy) │ │ +│ │ • tools (copy) │ │ +│ └──────────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────┐ +│ 3. AGENT LOOP STARTS │ +│ File: packages/agent/src/agent-loop.ts:95 │ +│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ +│ │ runPromptMessages() → runAgentLoop() │ │ +│ │ • Prompts added to context.messages │ │ +│ │ • Lifecycle events emitted for prompts │ │ +│ │ • Calls runLoop() (main processing loop) │ │ +│ └──────────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────┐ +│ 4. LLM CALL BOUNDARY - MESSAGE TRANSFORMATION │ +│ File: packages/agent/src/agent-loop.ts:281-372 (streamAssistantResponse) │ +│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ +│ │ Step 4.1: Context Transform (optional) │ │ +│ │ transformContext(messages) → transformed messages │ │ +│ │ (Used for context pruning/injection) │ │ +│ │ │ │ +│ │ Step 4.2: LLM Conversion │ │ +│ │ convertToLlm(messages) → Message[] │ │ +│ │ - Filters non-LLM messages (bashExecution, branchSummary, etc.) │ │ +│ │ - Converts: user → user, assistant → assistant, toolResult → toolResult │ │ +│ │ │ │ +│ │ Step 4.3: Build LLM Context │ │ +│ │ { │ │ +│ │ systemPrompt: context.systemPrompt, │ │ +│ │ messages: llmMessages, │ │ +│ │ tools: context.tools │ │ +│ │ } │ │ +│ │ │ │ +│ │ Step 4.4: Resolve API Key │ │ +│ │ getApiKey(model.provider) → apiKey │ │ +│ │ │ │ +│ │ Step 4.5: Stream Function Call │ │ +│ │ streamFunction(model, llmContext, options) │ │ +│ │ - Default: Models.streamSimple() from @earendil-works/pi-ai │ │ +│ │ - Makes actual LLM API call │ │ +│ └──────────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────┐ +│ 5. LLM RESPONSE STREAMING │ +│ File: packages/agent/src/agent-loop.ts:317-371 │ +│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ +│ │ Stream yields events: │ │ +│ │ • start → creates partial AssistantMessage │ │ +│ │ • text_start → streaming text begins │ │ +│ │ • text_delta → text chunks arrive │ │ +│ │ • toolcall_start → tool call block begins │ │ +│ │ • toolcall_delta → tool call arguments arrive │ │ +│ │ • toolcall_end → tool call block complete │ │ +│ │ • text_end → text block complete │ │ +│ │ • done → final message complete │ │ +│ │ │ │ +│ │ State Updates: │ │ +│ │ • Partial message pushed to context.messages │ │ +│ │ • message_start event emitted │ │ +│ │ • message_update events emitted as text/tools stream in │ │ +│ │ • Final message committed to context.messages │ │ +│ │ • message_end event emitted │ │ +│ │ │ │ +│ │ Stop Reasons: │ │ +│ │ • stop - normal completion │ │ +│ │ • toolUse - model requested tool calls │ │ +│ │ • length - token limit reached │ │ +│ │ • error - failure │ │ +│ │ • aborted - operation aborted │ │ +│ └──────────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────┐ +│ 6. TOOL CALL PARSING AND EXECUTION │ +│ File: packages/agent/src/agent-loop.ts:408-554 │ +│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ +│ │ Step 6.1: Extract Tool Calls │ │ +│ │ toolCalls = message.content.filter(c => c.type === "toolCall") │ │ +│ │ │ │ +│ │ Step 6.2: Determine Execution Mode │ │ +│ │ - Check config.toolExecution: "parallel" or "sequential" │ │ +│ │ - Check individual tool executionMode setting │ │ +│ │ - Decides how to execute tool batch │ │ +│ │ │ │ +│ │ Step 6.3: For Each Tool Call │ │ +│ │ ┌──────────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ 1. Tool Lookup │ │ │ +│ │ │ tool = context.tools.find(t => t.name === toolCall.name) │ │ │ +│ │ │ │ │ │ +│ │ │ 2. Argument Preparation │ │ │ +│ │ │ prepared = tool.prepareArguments?(toolCall.arguments) │ │ │ +│ │ │ │ │ │ +│ │ │ 3. Argument Validation │ │ │ +│ │ │ validateToolArguments(tool, preparedToolCall) │ │ │ +│ │ │ │ │ │ +│ │ │ 4. Before Tool Hook │ │ │ +│ │ │ beforeToolCall({ assistantMessage, toolCall, args, context }) │ │ │ +│ │ │ - Can block execution by returning { block: true, reason } │ │ │ +│ │ │ │ │ │ +│ │ │ 5. Execution │ │ │ +│ │ │ execute(toolCallId, params, signal, onUpdate, context) │ │ │ +│ │ │ │ │ │ +│ │ │ - Parallel Mode: Tools execute concurrently │ │ │ +│ │ │ - Sequential Mode: Tools execute one-by-one │ │ │ +│ │ └──────────────────────────────────────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────┐ +│ 7. TOOL EXECUTION EXAMPLE - READ TOOL │ +│ File: packages/agent/src/harness/tools/read.ts │ +│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ +│ │ User asks: "what product do you have in stock?" │ │ +│ │ │ │ +│ │ Agent decides to read catalog file "products.json" │ │ +│ │ │ │ +│ │ Input Arguments: │ │ +│ │ { │ │ +│ │ "path": "products.json", │ │ +│ │ "offset": 1, │ │ +│ │ "limit": 100 │ │ +│ │ } │ │ +│ │ │ │ +│ │ Execution Steps: │ │ +│ │ 1. resolveReadToolPath(env, path, signal) → absolutePath │ │ +│ │ 2. env.readBinaryFile(absolutePath, signal) → bytes │ │ +│ │ 3. Detect mimeType (check if image) │ │ +│ │ 4. For text files: │ │ +│ │ - Decode UTF-8 → textContent │ │ +│ │ - Split by lines → allLines │ │ +│ │ - Apply offset/limit slicing │ │ +│ │ - Truncate if exceeds DEFAULT_MAX_BYTES or DEFAULT_MAX_LINES │ │ +│ │ - Add truncation notice to output │ │ +│ │ 5. Return result: │ │ +│ │ { │ │ +│ │ content: [{ type: "text", text: output }], │ │ +│ │ details: { truncation: ... } │ │ +│ │ } │ │ +│ │ │ │ +│ │ Output Example: │ │ +│ │ "Showing lines 1-50 of 150. [Showing 50 lines of 150. Use offset=51 to │ │ +│ │ continue.]" │ │ +│ │ │ │ +│ │ Tool Result: │ │ +│ │ { │ │ +│ │ "role": "toolResult", │ │ +│ │ "toolCallId": "tool_abc123", │ │ +│ │ "toolName": "read", │ │ +│ │ "content": [{ "type": "text", "text": "..." }], │ │ +│ │ "isError": false, │ │ +│ │ "timestamp": 1721721600000 │ │ +│ │ } │ │ +│ └──────────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────┐ +│ 8. TOOL RESULT HANDLING │ +│ File: packages/agent/src/agent-loop.ts:556-792 │ +│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ +│ │ Step 8.1: After Tool Hook │ │ +│ │ afterToolCall({ assistantMessage, toolCall, args, result, isError, ctx }) │ │ +│ │ - Can override result content, details, usage, terminate hint │ │ +│ │ │ │ +│ │ Step 8.2: Create Tool Result Message │ │ +│ │ { │ │ +│ │ role: "toolResult", │ │ +│ │ toolCallId: toolCall.id, │ │ +│ │ toolName: toolCall.name, │ │ +│ │ content: result.content ?? [], │ │ +│ │ details: result.details, │ │ +│ │ usage: result.usage, │ │ +│ │ isError: false, │ │ +│ │ timestamp: Date.now() │ │ +│ │ } │ │ +│ │ │ │ +│ │ Step 8.3: Emit Events │ │ +│ │ • tool_execution_start │ │ +│ │ • tool_execution_end │ │ +│ │ • message_start (toolResult message) │ │ +│ │ • message_end (toolResult message) │ │ +│ │ │ │ +│ │ Step 8.4: Update Context │ │ +│ │ • Push tool result message to currentContext.messages │ │ +│ │ • Push to newMessages array │ │ +│ │ │ │ +│ │ Step 8.5: Batch Termination Check │ │ +│ │ shouldTerminateToolBatch(finalizedCalls) │ │ +│ │ - Returns true if ALL tools set terminate: true │ │ +│ │ - If true, agent may stop after this batch │ │ +│ └──────────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────┐ +│ 9. NEXT TURN PREPARATION │ +│ File: packages/agent/src/agent-loop.ts:224-257 │ +│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ +│ │ Step 9.1: Turn End Event │ │ +│ │ turn_end emitted with message and toolResults │ │ +│ │ │ │ +│ │ Step 9.2: prepareNextTurn Hook │ │ +│ │ prepareNextTurn({ message, toolResults, context, newMessages }) │ │ +│ │ - Can return updated context, model, or thinking level │ │ +│ │ - Used for dynamic context management │ │ +│ │ │ │ +│ │ Step 9.3: Queue Polling │ │ +│ │ getSteeringMessages() → inject messages for immediate processing │ │ +│ │ getFollowUpMessages() → check for queued follow-up messages │ │ +│ │ │ │ +│ │ Step 9.4: Loop Decision │ │ +│ │ • If steering messages exist → process them, continue loop │ │ +│ │ • If follow-up messages exist → process them, continue loop │ │ +│ │ • If tool calls remain in message → continue inner loop │ │ +│ │ • If no messages → emit agent_end, exit loop │ │ +│ └──────────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────┐ +│ 10. AGENT RESPONSE GENERATION │ +│ File: packages/agent/src/agent-loop.ts:58-163 │ +│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ +│ │ The agent loop continues until: │ │ +│ │ • No tool calls remain in assistant messages │ │ +│ │ • No steering/follow-up messages queued │ │ +│ │ • shouldStopAfterTurn() returns true (if configured) │ │ +│ │ │ │ +│ │ Final Response Generation: │ │ +│ │ 1. LLM streams text content blocks │ │ +│ │ 2. Message committed to context │ │ +│ │ 3. turn_end emitted │ │ +│ │ 4. agent_end emitted with all new messages │ │ +│ │ 5. Agent returns to idle state │ │ +│ │ │ │ +│ │ Final Agent Response: │ │ +│ │ "We have 15 products in stock:" │ │ +│ │ • Product A - $29.99 │ │ +│ │ • Product B - $49.99 │ │ +│ │ • Product C - $19.99 │ │ +│ │ (and 2 more products) │ │ +│ └──────────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────────┘ + +``` + +--- + +## Complete Event Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────────────────┐ +│ COMPLETE EVENT SEQUENCE FOR A TURN │ +│ WITH TOOL USE (Product Catalog Query) │ +└─────────────────────────────────────────────────────────────────────────────────────┘ + +agent_start + ↓ +turn_start + ↓ +message_start (user message: "what product do you have in stock?") +message_end (user message) + ↓ +message_start (assistant message - streaming from LLM) +message_update (text delta: "We have") +message_update (toolcall delta: {"name":"read","arguments":{...}}) +message_update (toolcall end) +message_end (assistant message with tool calls) + ↓ +tool_execution_start (tool call: read products.json) +tool_execution_end (tool call: read complete) +message_start (toolResult message) +message_end (toolResult message) + ↓ +turn_end + ↓ +[Inner loop continues: send tool result to LLM] + ↓ +turn_start + ↓ +message_start (assistant message - streaming from LLM) +message_update (text delta: "We have 15 products in stock:") +message_update (text delta: "• Product A - $29.99") +message_update (text delta: "• Product B - $49.99") +message_end (final assistant message) + ↓ +turn_end + ↓ +agent_end +``` + +--- + +## Key Files Summary + +| Component | File | Purpose | +|-----------|------|---------| +| Agent class | `packages/agent/src/agent.ts` | Stateful wrapper, event emission, queue management | +| Agent loop | `packages/agent/src/agent-loop.ts` | Core loop, LLM calls, tool execution | +| Message types | `packages/agent/src/types.ts` | AgentMessage, AgentTool, AgentEvent definitions | +| Harness | `packages/agent/src/harness/agent-harness.ts` | Session integration, hooks, persistence | +| Read tool | `packages/agent/src/harness/tools/read.ts` | File reading implementation | +| Stream function | `packages/agent/src/stream-fn.ts` | Default stream function management | +| Types | `packages/agent/src/types.ts` | All type definitions | + +--- + +## Hook Points for Customization + +The agent supports multiple extension points: + +| Hook | Location | Purpose | +|------|----------|---------| +| `convertToLlm` | `agent.ts:99` | Transform messages before LLM call | +| `transformContext` | `agent.ts:100` | Modify context (pruning, injection) | +| `beforeToolCall` | `agent.ts:105` | Block or modify tool execution | +| `afterToolCall` | `agent.ts:106` | Override tool results | +| `prepareNextTurn` | `agent.ts:107` | Dynamic context/model updates | +| `shouldStopAfterTurn` | `agent-loop.ts` | Graceful termination | +| `getSteeringMessages` | `agent.ts:114` | Inject messages mid-turn | +| `getFollowUpMessages` | `agent.ts:115` | Queue follow-up messages | + +--- + +## Tool Execution Flow + +``` +Tool Call Received from LLM + ↓ +1. Tool Lookup (find by name) + ↓ +2. prepareArguments? (transform if defined) + ↓ +3. validateToolArguments (JSON Schema) + ↓ +4. beforeToolCall hook (can block) + ↓ +5. execute (parallel or sequential) + ↓ +6. onUpdate (stream partial results) + ↓ +7. afterToolCall hook (can override) + ↓ +8. Create toolResult message + ↓ +9. Emit events (start, end) + ↓ +10. Add to context.messages +``` + +--- + +## Example: "What product do you have in stock?" + +### Step-by-Step Execution: + +1. **User sends message** + ``` + "what product do you have in stock?" + ``` + +2. **Agent normalizes input** + ```typescript + [{ + role: "user", + content: [{ type: "text", text: "what product do you have in stock?" }], + timestamp: Date.now() + }] + ``` + +3. **LLM processes and decides to use `read` tool** + ```json + { + "role": "assistant", + "content": [{ + "type": "toolCall", + "name": "read", + "arguments": { + "path": "products.json", + "offset": 1, + "limit": 50 + }, + "id": "tool_abc123" + }] + } + ``` + +4. **Tool execution** + - Read `products.json` (150 lines total) + - Return lines 1-50 with truncation notice + - Add to context as toolResult + +5. **LLM generates final response** + ``` + We have 15 products in stock: + + • Product A - $29.99 + • Product B - $49.99 + • Product C - $19.99 + • Product D - $99.99 + • Product E - $149.99 + (and 10 more products) + + Use offset=51 to continue viewing. + ``` + +6. **Agent emits final response to user** + +--- + +## Summary + +The agent workflow follows a clear pattern: + +1. **Message Input** → Normalize and validate +2. **Context Setup** → Create snapshot with system prompt, messages, tools +3. **LLM Call** → Transform messages, resolve API key, stream response +4. **Tool Detection** → Check for tool calls in assistant message +5. **Tool Execution** → Validate, hook, execute, stream updates +6. **Result Handling** → Create toolResult message, emit events +7. **Next Turn** → Check for steering/follow-up messages, prepare context +8. **Response Generation** → Continue until no more tool calls needed +9. **Completion** → Emit final response to user + +The entire flow is event-driven, allowing for real-time updates and hook-based customization at every step. diff --git a/learn_pi_agent/architecture.md b/learn_pi_agent/architecture.md new file mode 100644 index 0000000..cc49527 --- /dev/null +++ b/learn_pi_agent/architecture.md @@ -0,0 +1,2798 @@ +# Agent Architecture: Pi Agent Core - A Comprehensive Guide for Julia Implementation + +## Table of Contents +1. [Overview](#overview) +2. [Architecture Layers](#architecture-layers) +3. [Core Components](#core-components) +4. [Message System](#message-system) +5. [Agent Loop](#agent-loop) +6. [Tool Execution](#tool-execution) +7. [Session Management](#session-management) +8. [Memory & Context Management](#memory--context-management) +9. [Event System](#event-system) +10. [Hook System](#hook-system) +11. [Implementation Guide for Julia](#implementation-guide-for-julia) +12. [Data Flow Diagrams](#data-flow-diagrams) +13. [Key Algorithms](#key-algorithms) + +--- + +## Overview + +The Pi Agent Core is a sophisticated stateful agent system with the following characteristics: + +- **Stateful execution**: Maintains conversation history and context across multiple turns +- **Tool execution**: Supports LLM tool calling with parallel/sequential execution modes +- **Event streaming**: Real-time event system for UI updates +- **Session persistence**: JSONL-based persistent storage with tree-structured branching +- **Memory compaction**: Automatic context window management through LLM summarization +- **Flexible extension**: Hook-based customization at every system boundary + +### Key Design Principles + +1. **Separation of concerns**: Core agent logic is separated from storage and provider implementations +2. **Streaming first**: All operations are designed around async streams for responsiveness +3. **Type safety**: Strong TypeScript types for compile-time guarantees +4. **Extensibility**: Hooks at every major boundary allow customization +5. **Persistence**: Session history survives restarts through JSONL files + +--- + +## Architecture Layers + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ User Interface │ +│ (VS Code Extension, CLI, etc.) │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Agent Harness (agent-harness.ts) │ +│ - Session integration │ +│ - Hook system │ +│ - Skill/prompt template management │ +│ - System prompt building │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Agent Class (agent.ts) │ +│ - State management (messages, tools, model) │ +│ - Event emission │ +│ - Steering/follow-up queues │ +│ - Active run management │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Agent Loop (agent-loop.ts) │ +│ - Main execution loop │ +│ - LLM call orchestration │ +│ - Tool execution │ +│ - Context transformation │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Provider Abstraction │ +│ (@earendil-works/pi-ai - external) │ +│ - LLM API calls │ +│ - Streaming interface │ +│ - Retry policies │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Session Storage (session/) │ +│ - JSONL file persistence │ +│ - Tree-structured history │ +│ - Compaction and summarization │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Core Components + +### 1. Agent Class (`agent.ts`) + +**Purpose**: Stateful wrapper around the low-level agent loop + +**Key responsibilities**: +- Maintain in-memory transcript (`_state.messages`) +- Manage tools, system prompt, model configuration +- Emit lifecycle events to subscribers +- Handle steering/follow-up message queues +- Prevent concurrent runs (single active run at a time) + +**State structure**: +```typescript +interface AgentState { + systemPrompt: string + model: Model + thinkingLevel: ThinkingLevel + tools: AgentTool[] + messages: AgentMessage[] + isStreaming: boolean + streamingMessage?: AgentMessage + pendingToolCalls: Set + errorMessage?: string +} +``` + +**Key methods**: +- `prompt(message)`: Start a new prompt from text, message, or array +- `continue()`: Continue from current context +- `steer(message)`: Queue steering message (interruption during tool execution) +- `followUp(message)`: Queue follow-up message (executes after agent stops) +- `reset()`: Clear all state +- `subscribe(listener)`: Register event listener +- `abort()`: Cancel current operation +- `waitForIdle()`: Wait for completion and event listeners + +### 2. Agent Loop (`agent-loop.ts`) + +**Purpose**: Core execution logic without state management + +**Main entry points**: +- `runAgentLoop(prompts, context, config, emit, signal, streamFn)`: Start new loop with prompts +- `runAgentLoopContinue(context, config, emit, signal, streamFn)`: Continue from existing context + +**Execution phases**: + +#### Phase 1: Outer Loop (Turn Management) +``` +while (true): + 1. Check steering messages (inject if any) + 2. Stream assistant response from LLM + 3. Extract tool calls from response + 4. Execute tool batch + 5. Emit turn_end event + 6. Check prepareNextTurn hook + 7. Check shouldStopAfterTurn hook + 8. Check follow-up messages + 9. If follow-up exists, continue outer loop + 10. If no follow-up, exit +``` + +#### Phase 2: Inner Loop (Tool Call Processing) +``` +while (hasMoreToolCalls || pendingMessages): + 1. Process pending messages + 2. Stream assistant response + 3. Extract tool calls + 4. Execute tool batch (parallel or sequential) + 5. Emit turn_end +``` + +#### Phase 3: LLM Call Boundary +``` +1. transformContext(messages) // Optional pruning/injection +2. convertToLlm(messages) // Filter/custom message conversion +3. Build LLM context +4. Resolve API key +5. Call streamFunction(model, context, options) +6. Stream events from LLM +7. Commit final message to context +``` + +**Tool execution modes**: +- **Parallel** (default): Preflight sequentially, execute allowed tools concurrently +- **Sequential**: Execute tools one-by-one + +### 3. Types System (`types.ts`) + +**Key types**: + +#### AgentMessage +```typescript +type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages] +``` +Extensible union of LLM messages and custom app-specific messages. + +#### AgentTool +```typescript +interface AgentTool extends Tool { + label: string // UI display name + prepareArguments?: (args) => Static // Argument transformation + execute: (toolCallId, params, signal, onUpdate) => Promise + executionMode?: "parallel" | "sequential" // Per-tool override +} +``` + +#### AgentContext +```typescript +interface AgentContext { + systemPrompt: string + messages: AgentMessage[] + tools?: AgentTool[] +} +``` + +#### AgentLoopConfig +Configuration object passed to low-level loop functions, including: +- Model specification +- convertToLlm transformation +- transformContext (optional) +- beforeToolCall hook +- afterToolCall hook +- prepareNextTurn hook +- shouldStopAfterTurn hook +- getSteeringMessages hook +- getFollowUpMessages hook + +--- + +## Message System + +### Message Types + +#### LLM Messages (standard) +- `user`: User input +- `assistant`: LLM response (streaming, contains tool calls) +- `toolResult`: Tool execution result + +#### Custom Messages (app-specific) +- `bashExecution`: Shell command execution result (hidden from LLM by default) +- `custom`: Custom app messages (visible to LLM if projected) +- `branchSummary`: Branch divergence summary +- `compactionSummary`: History compaction result + +### Message Flow + +``` +User Input + ↓ +normalizePromptInput() → AgentMessage[] + ↓ +AgentContext.messages (in-memory) + ↓ +transformContext() (optional, for pruning/injection) + ↓ +convertToLlm() (filters/custom → LLM format) + ↓ +LLM provider (Message[]) + ↓ +AssistantMessage (streamed) + ↓ +AgentContext.messages (committed) +``` + +### Message Commitment + +Messages are committed to `context.messages` at specific points: +1. Assistant partial message: Added when `start` event arrives +2. Assistant final message: Replaces partial on `done`/`error` +3. Tool result message: Added after `tool_execution_end` + +--- + +## Agent Loop + +### Main Algorithm + +```typescript +async function runLoop(initialContext, newMessages, config, signal, emit, streamFn) { + let currentContext = initialContext + let pendingMessages = (await config.getSteeringMessages?.()) || [] + + while (true) { + let hasMoreToolCalls = true + + while (hasMoreToolCalls || pendingMessages.length > 0) { + // Process pending messages (steering/follow-up) + if (pendingMessages.length > 0) { + for (const msg of pendingMessages) { + await emit({ type: "message_start", message: msg }) + await emit({ type: "message_end", message: msg }) + currentContext.messages.push(msg) + newMessages.push(msg) + } + pendingMessages = [] + } + + // Stream assistant response + const message = await streamAssistantResponse( + currentContext, config, signal, emit, streamFn + ) + newMessages.push(message) + + // Check for errors + if (message.stopReason === "error" || message.stopReason === "aborted") { + await emit({ type: "turn_end", message, toolResults: [] }) + await emit({ type: "agent_end", messages: newMessages }) + return + } + + // Extract and execute tool calls + const toolCalls = message.content.filter(c => c.type === "toolCall") + const toolResults = [] + hasMoreToolCalls = false + + if (toolCalls.length > 0) { + const executedBatch = await executeToolCalls( + currentContext, message, config, signal, emit + ) + toolResults.push(...executedBatch.messages) + hasMoreToolCalls = !executedBatch.terminate + + for (const result of toolResults) { + currentContext.messages.push(result) + newMessages.push(result) + } + } + + await emit({ type: "turn_end", message, toolResults }) + + // Check prepareNextTurn hook + const nextTurnSnapshot = await config.prepareNextTurn?.({ + message, toolResults, context: currentContext, newMessages + }) + if (nextTurnSnapshot) { + currentContext = nextTurnSnapshot.context ?? currentContext + config = { ...config, model: nextTurnSnapshot.model ?? config.model } + } + + // Check shouldStopAfterTurn hook + if (await config.shouldStopAfterTurn?.({ ... })) { + await emit({ type: "agent_end", messages: newMessages }) + return + } + + // Get steering messages for next iteration + pendingMessages = (await config.getSteeringMessages?.()) || [] + } + + // Check follow-up messages + const followUpMessages = (await config.getFollowUpMessages?.()) || [] + if (followUpMessages.length > 0) { + pendingMessages = followUpMessages + continue + } + + // No more messages, exit + break + } + + await emit({ type: "agent_end", messages: newMessages }) +} +``` + +### Assistant Response Streaming + +```typescript +async function streamAssistantResponse(context, config, signal, emit, streamFn) { + // 1. Transform context (optional) + let messages = context.messages + if (config.transformContext) { + messages = await config.transformContext(messages, signal) + } + + // 2. Convert to LLM format + const llmMessages = await config.convertToLlm(messages) + + // 3. Build LLM context + const llmContext = { + systemPrompt: context.systemPrompt, + messages: llmMessages, + tools: context.tools, + } + + // 4. Resolve API key + const resolvedApiKey = await config.getApiKey?.(config.model.provider) || config.apiKey + + // 5. Call stream function + const response = await streamFn(config.model, llmContext, { + ...config, apiKey: resolvedApiKey, signal + }) + + // 6. Stream events + let partialMessage: AssistantMessage | null = null + let addedPartial = false + + for await (const event of response) { + switch (event.type) { + case "start": + partialMessage = event.partial + context.messages.push(partialMessage) + addedPartial = true + await emit({ type: "message_start", message: { ...partialMessage } }) + break + + case "text_start" | "text_delta" | "text_end" | + "thinking_start" | "thinking_delta" | "thinking_end" | + "toolcall_start" | "toolcall_delta" | "toolcall_end": + if (partialMessage) { + partialMessage = event.partial + context.messages[context.messages.length - 1] = partialMessage + await emit({ + type: "message_update", + assistantMessageEvent: event, + message: { ...partialMessage } + }) + } + break + + case "done" | "error": { + const finalMessage = await response.result() + if (addedPartial) { + context.messages[context.messages.length - 1] = finalMessage + } else { + context.messages.push(finalMessage) + await emit({ type: "message_start", message: { ...finalMessage } }) + } + await emit({ type: "message_end", message: finalMessage }) + return finalMessage + } + } + } +} +``` + +--- + +## Tool Execution + +### Tool Call Flow + +``` +Assistant Message (with toolCall content blocks) + ↓ +1. Extract tool calls from message.content +2. Determine execution mode (parallel/sequential) +3. For each tool: + - Look up tool by name + - Prepare arguments (prepareArguments hook) + - Validate arguments (JSON Schema) + - beforeToolCall hook (can block) + - Execute tool (parallel or sequential) + - onUpdate stream (optional) + - afterToolCall hook (can override result) + - Create toolResult message + - Emit events + ↓ +4. Check shouldTerminateToolBatch +5. Return toolResult messages +``` + +### Parallel vs Sequential Execution + +#### Parallel Execution +```typescript +async function executeToolCallsParallel(...) { + const finalizedCalls = [] + + // Preflight all tools sequentially + for (const toolCall of toolCalls) { + const preparation = await prepareToolCall(...) + + if (preparation.kind === "immediate") { + finalizedCalls.push(preparation) + } else { + // Queue async execution + finalizedCalls.push(async () => { + const executed = await executePreparedToolCall(preparation, signal, emit) + const finalized = await finalizeExecutedToolCall(...) + return finalized + }) + } + } + + // Execute allowed tools concurrently + const orderedFinalizedCalls = await Promise.all( + finalizedCalls.map(entry => typeof entry === "function" ? entry() : Promise.resolve(entry)) + ) + + // Emit toolResult messages in assistant source order + const messages = [] + for (const finalized of orderedFinalizedCalls) { + const toolResultMessage = createToolResultMessage(finalized) + await emitToolResultMessage(toolResultMessage, emit) + messages.push(toolResultMessage) + } + + return { messages, terminate: shouldTerminateToolBatch(orderedFinalizedCalls) } +} +``` + +#### Sequential Execution +```typescript +async function executeToolCallsSequential(...) { + const finalizedCalls = [] + const messages = [] + + for (const toolCall of toolCalls) { + const preparation = await prepareToolCall(...) + let finalized + + if (preparation.kind === "immediate") { + finalized = preparation + } else { + const executed = await executePreparedToolCall(preparation, signal, emit) + finalized = await finalizeExecutedToolCall(...) + } + + await emitToolExecutionEnd(finalized, emit) + const toolResultMessage = createToolResultMessage(finalized) + await emitToolResultMessage(toolResultMessage, emit) + finalizedCalls.push(finalized) + messages.push(toolResultMessage) + } + + return { messages, terminate: shouldTerminateToolBatch(finalizedCalls) } +} +``` + +### Tool Execution Stages + +1. **Preparation** (`prepareToolCall`) + - Look up tool by name + - Call `prepareArguments` if defined + - Validate with `validateToolArguments` + - Call `beforeToolCall` hook (can block with `{ block: true }`) + - Return `PreparedToolCall` or `ImmediateToolCallOutcome` + +2. **Execution** (`executePreparedToolCall`) + - Call `tool.execute(toolCallId, args, signal, onUpdate)` + - Stream partial results via `onUpdate` + - Handle errors, return `ExecutedToolCallOutcome` + +3. **Finalization** (`finalizeExecutedToolCall`) + - Call `afterToolCall` hook (can override result) + - Return `FinalizedToolCallOutcome` + +4. **Message Creation** (`createToolResultMessage`) + - Create `ToolResultMessage` + - Set `toolCallId`, `toolName`, `content`, `details`, `usage`, `isError`, `timestamp` + +--- + +## Session Management + +### JSONL Storage Format + +```jsonl +{"type":"session","version":3,"id":"abc123","timestamp":"2024-01-15T10:30:00.000Z", + "cwd":"/home/user/project","parentSession":"...","metadata":{}} +{"type":"message","id":"e001","parentId":null,"timestamp":"...","message":{...}} +{"type":"message","id":"e002","parentId":"e001","timestamp":"...","message":{...}} +{"type":"compaction","id":"e003","parentId":"e002","timestamp":"...", + "summary":"## Goal: ...\n...","firstKeptEntryId":"e001", + "tokensBefore":185000} +{"type":"leaf","id":"e004","parentId":"e003","timestamp":"...", + "targetId":"e002"} +``` + +### Entry Types + +| Type | LLM Context? | Purpose | +|------|-------------|---------| +| `message` | Yes | User, assistant, toolResult | +| `compaction` | Yes (as summary message) | Replaces compacted history | +| `branch_summary` | Yes (as summary message) | Summary of diverged branch | +| `leaf` | No | Points to current tree leaf | +| `thinking_level_change` | No | Tracking thinking level changes | +| `model_change` | No | Tracking model changes | +| `active_tools_change` | No | Tracking tool enable/disable | +| `custom` | No (unless projected) | App-defined data | +| `custom_message` | Yes | App-defined messages | +| `label` | No | Human-readable labels | +| `session_info` | No | Session name history | + +### Session Tree (Branching) + +Sessions form a **tree**, not a linear log: + +``` + ┌───[e01]───┐ + │ user: "a" │ + └─────┬──────┘ + ▼ + ┌──────────┐ + │ assist 1 │ + └─────┬────┘ + ▼ + ┌──────────┐ + │ toolCall │ + └─────┬────┘ + ▼ + ┌──────────┐ + │ toolRes 1│ + └─────┬────┘ + ▼ + ┌──────────┐ + │ user: "b"│ ← user goes back here + └─────┬────┘ + │ + ┌─────┴─────┐ + │ │ + ┌──────────┐ ┌──────────┐ + │ user: "c" │ │ user: "d" │ ← branch point + └────┬─────┘ └────┬─────┘ + │ │ + ┌────▼─────┐ ┌────▼─────┐ + │ assist 2 │ │ assist 3 │ ← current leaf (d) + └──────────┘ └──────────┘ +``` + +**Branch navigation**: +1. User navigates to a different point in history +2. Leaf moves to the selected entry +3. Branch summary generated for diverged work +4. New work branches from the selected point + +### Session API + +```typescript +class Session { + async getBranch(fromId?: string): Promise + async buildContext(options?: SessionContextBuildOptions): Promise + async getLeafId(): Promise + async setLeafId(id: string): Promise + async appendMessage(message: AgentMessage): Promise + async appendCompaction(summary: string, firstKeptEntryId: string, tokensBefore: number): Promise + async appendBranchSummary(summary: string, fromId: string): Promise + async appendLeaf(targetId: string): Promise +} +``` + +--- + +## Memory & Context Management + +### Token Estimation + +```typescript +function estimateTokens(message: AgentMessage): number { + switch (message.role) { + case "user": + return message.content.length / 4 // 4 chars ≈ 1 token + + case "assistant": + return message.content.reduce((sum, block) => { + if (block.type === "text") return sum + block.text.length + if (block.type === "thinking") return sum + block.thinking.length + if (block.type === "toolCall") return sum + block.name.length + JSON.stringify(block.arguments).length + return sum + }, 0) + + case "toolResult" | "custom": + return message.content.length / 4 + + case "bashExecution": + return (message.command.length + message.output.length) / 4 + + case "compactionSummary" | "branchSummary": + return message.summary.length / 4 + } +} +``` + +### Compaction Strategy + +**Trigger condition**: +```typescript +function shouldCompact(contextTokens, contextWindow, settings) { + return contextTokens > contextWindow - settings.reserveTokens +} + +// Defaults +const DEFAULT_COMPACTION_SETTINGS = { + enabled: true, + reserveTokens: 16384, // ~16K for summary prompt + output + keepRecentTokens: 20000 // ~20K tokens of recent history +} +``` + +**Cut point finding**: +```typescript +function findCutPoint(entries, startIndex, endIndex, keepRecentTokens) { + let accumulated = 0 + + // Walk backward from endIndex + for (let i = endIndex - 1; i >= startIndex; i--) { + const entry = entries[i] + + // Skip invalid cut points (toolResult stays with its call) + if (entry.type === "message" && entry.message.role === "toolResult") { + continue + } + + const tokens = estimateTokens(entry) + accumulated += tokens + + if (accumulated >= keepRecentTokens) { + return i + 1 // Snap to nearest valid cut point + } + } + + return startIndex +} +``` + +**Compaction preparation**: +```typescript +function prepareCompaction(branchEntries, settings) { + // Find previous compaction + let previousCompaction = null + for (const entry of branchEntries) { + if (entry.type === "compaction") { + previousCompaction = entry + } + } + + // Estimate tokens + const contextTokens = estimateContextTokens(branchEntries) + + // Find cut point + const firstKeptEntryId = findCutPoint(branchEntries, 0, branchEntries.length, settings.keepRecentTokens) + + // Split into groups + const messagesToSummarize = branchEntries.slice(0, firstKeptEntryId) + const retainedTail = branchEntries.slice(firstKeptEntryId) + + // Extract file operations + const fileOps = extractFileOperations(messagesToSummarize, branchEntries, prevIndex) + + return { + previousCompaction, + contextTokens, + firstKeptEntryId, + messagesToSummarize, + retainedTail, + turnPrefixMessages: extractTurnPrefix(messagesToSummarize), + fileOps + } +} +``` + +**Summary generation**: +```typescript +async function generateSummary(messages, previousSummary) { + if (previousSummary) { + // UPDATE_SUMMARIZATION_PROMPT (iterative update) + const prompt = ` + + ${previousSummary} + + + + ${serializeConversation(messages)} + + + Update the previous summary with new progress: + - Add completed tasks + - Update progress + - Add new goals + - Keep existing information + ` + + return await models.completeSimple(model, { systemPrompt, messages: [{ role: "user", content: prompt }] }) + } else { + // FRESH_SUMMARIZATION_PROMPT + const prompt = ` + + ${serializeConversation(messages)} + + + Generate a summary: + ## Goal + ## Constraints & Preferences + ## Progress + ### Done + ### In Progress + ### Blocked + ## Key Decisions + ## Next Steps + ## Critical Context + ## Files read: [...] + ## Files modified: [...] + ` + + return await models.completeSimple(model, { systemPrompt, messages: [{ role: "user", content: prompt }] }) + } +} +``` + +--- + +## Event System + +### Event Types + +#### Agent Lifecycle +- `agent_start`: Agent begins processing +- `agent_end`: Final event for the run + +#### Turn Lifecycle +- `turn_start`: New turn begins +- `turn_end`: Turn completes with assistant message and tool results + +#### Message Lifecycle +- `message_start`: Any message begins +- `message_update`: **Assistant only**. Includes `assistantMessageEvent` with delta +- `message_end`: Message completes + +#### Tool Execution Lifecycle +- `tool_execution_start`: Tool begins +- `tool_execution_update`: Tool streams progress +- `tool_execution_end`: Tool completes + +### Event Flow with Tools + +``` +agent_start + ↓ +turn_start + ↓ +message_start { user message } +message_end { user message } + ↓ +message_start { assistant message - streaming } +message_update { text_delta: "We have" } +message_update { toolcall_delta: {"name":"read","arguments":{...}} } +message_update { toolcall_end } +message_end { assistant message with tool calls } + ↓ +tool_execution_start { tool call: read products.json } +tool_execution_end { tool call: complete } +message_start { toolResult message } +message_end { toolResult message } + ↓ +turn_end { message, toolResults: [toolResult] } + ↓ +[Inner loop continues: send tool result to LLM] + ↓ +turn_start + ↓ +message_start { assistant message } +message_update { text_delta: "We have 15 products" } +message_end { final assistant message } + ↓ +turn_end + ↓ +agent_end +``` + +### Subscription Model + +```typescript +const unsubscribe = agent.subscribe(async (event, signal) => { + switch (event.type) { + case "message_update": + if (event.assistantMessageEvent.type === "text_delta") { + // Stream text to UI + process.stdout.write(event.assistantMessageEvent.delta) + } + break + + case "agent_end": + // Cleanup, save state, etc. + await flushSessionState(signal) + } +}) +``` + +**Subscription semantics**: +- Listeners are awaited in subscription order +- `agent_end` listeners are included in run settlement +- Agent becomes idle only after all awaited listeners finish +- All listeners receive the active abort signal + +--- + +## Hook System + +### Hook Points + +| Hook | Location | Purpose | +|------|----------|---------| +| `convertToLlm` | agent.ts:99 | Transform messages before LLM call | +| `transformContext` | agent.ts:100 | Modify context (pruning, injection) | +| `beforeToolCall` | agent.ts:105 | Block or modify tool execution | +| `afterToolCall` | agent.ts:106 | Override tool results | +| `prepareNextTurn` | agent.ts:107 | Dynamic context/model updates | +| `shouldStopAfterTurn` | agent-loop.ts | Graceful termination | +| `getSteeringMessages` | agent.ts:114 | Inject messages mid-turn | +| `getFollowUpMessages` | agent.ts:115 | Queue follow-up messages | + +### Hook Context Types + +#### BeforeToolCallContext +```typescript +interface BeforeToolCallContext { + assistantMessage: AssistantMessage + toolCall: AgentToolCall + args: unknown // Validated arguments + context: AgentContext +} +``` + +#### AfterToolCallContext +```typescript +interface AfterToolCallContext { + assistantMessage: AssistantMessage + toolCall: AgentToolCall + args: unknown + result: AgentToolResult + isError: boolean + context: AgentContext +} +``` + +#### PrepareNextTurnContext +```typescript +interface PrepareNextTurnContext extends ShouldStopAfterTurnContext {} + +interface ShouldStopAfterTurnContext { + message: AssistantMessage + toolResults: ToolResultMessage[] + context: AgentContext + newMessages: AgentMessage[] +} +``` + +### Hook Return Types + +#### BeforeToolCallResult +```typescript +interface BeforeToolCallResult { + block?: boolean // Prevent execution + reason?: string // Error message if blocked +} +``` + +#### AfterToolCallResult +```typescript +interface AfterToolCallResult { + content?: (TextContent | ImageContent)[] + details?: unknown + isError?: boolean + usage?: Usage + terminate?: boolean // Hint to stop after batch +} +``` + +#### AgentLoopTurnUpdate +```typescript +interface AgentLoopTurnUpdate { + context?: AgentContext + model?: Model + thinkingLevel?: ThinkingLevel +} +``` + +--- + +## Implementation Guide for Julia + +### Architecture Overview + +``` +Julia Agent Implementation +├── agent.jl # Core Agent class +├── agent_loop.jl # Low-level execution loop +├── messages.jl # Message types and conversion +├── tools.jl # Tool execution +├── session.jl # Session persistence +├── compaction.jl # Memory management +├── events.jl # Event system +├── hooks.jl # Hook system +├── types.jl # Type definitions +└── stream.jl # Stream utilities +``` + +### Step 1: Type Definitions (`types.jl`) + +```julia +# thinking_level.jl +@enum ThinkingLevel begin + THINKING_OFF + THINKING_MINIMAL + THINKING_LOW + THINKING_MEDIUM + THINKING_HIGH + THINKING_XHIGH + THINKING_MAX +end + +# tool.jl +mutable struct Tool{TParameters, TDetails} + name::String + label::String + description::String + parameters::TParameters + execute::Function + prepare_arguments::Union{Function, Nothing} + execution_mode::Symbol # :parallel or :sequential +end + +# message.jl +abstract type Message end + +struct UserMessage <: Message + content::Vector{Union{TextContent, ImageContent}} + timestamp::Int64 +end + +struct AssistantMessage <: Message + content::Vector{Union{TextContent, ToolCall, Thinking}} + api::String + provider::String + model::String + usage::Usage + stop_reason::String + error_message::Union{String, Nothing} + timestamp::Int64 +end + +struct ToolResultMessage <: Message + tool_call_id::String + tool_name::String + content::Vector{Union{TextContent, ImageContent}} + details::Any + usage::Union{Usage, Nothing} + is_error::Bool + timestamp::Int64 +end + +struct CustomMessage <: Message + custom_type::String + content::Union{String, Vector{Union{TextContent, ImageContent}}} + display::Bool + details::Any + timestamp::Int64 +end + +struct AgentMessage + message::Union{UserMessage, AssistantMessage, ToolResultMessage, CustomMessage} +end + +# context.jl +struct AgentContext + system_prompt::String + messages::Vector{AgentMessage} + tools::Union{Vector{Tool}, Nothing} +end +``` + +### Step 2: Message System (`messages.jl`) + +```julia +# messages.jl +const COMPACTION_SUMMARY_PREFIX = """ +The conversation history before this point was compacted into the following summary: + + +""" + +const COMPACTION_SUMMARY_SUFFIX = """ + +""" + +const BRANCH_SUMMARY_PREFIX = """ +The following is a summary of a branch that this conversation came back from: + + +""" + +const BRANCH_SUMMARY_SUFFIX = """ + +""" + +function convert_to_llm(messages::Vector{AgentMessage}) + llm_messages = Vector{Any}() + + for msg in messages + if msg.message isa UserMessage + push!(llm_messages, msg.message) + elseif msg.message isa AssistantMessage + push!(llm_messages, msg.message) + elseif msg.message isa ToolResultMessage + push!(llm_messages, msg.message) + elseif msg.message isa CustomMessage + # Convert custom to user message + content = if msg.message.content isa String + [TextContent(msg.message.content)] + else + msg.message.content + end + push!(llm_messages, UserMessage(content, msg.message.timestamp)) + end + end + + return llm_messages +end + +function create_branch_summary_message(summary::String, from_id::String, timestamp::String) + BranchSummaryMessage(summary, from_id, parse(Int64, timestamp)) +end + +function create_compaction_summary_message(summary::String, tokens_before::Int, timestamp::String) + CompactionSummaryMessage(summary, tokens_before, parse(Int64, timestamp)) +end +``` + +### Step 3: Tool Execution (`tools.jl`) + +```julia +# tools.jl +struct ToolExecutionResult{TDetails} + content::Vector{Union{TextContent, ImageContent}} + details::TDetails + usage::Union{Usage, Nothing} + added_tool_names::Vector{String} + terminate::Bool +end + +struct PreparedToolCall + tool_call::ToolCall + tool::Tool + args::Any +end + +struct ImmediateToolOutcome + result::ToolExecutionResult + is_error::Bool +end + +struct ExecutedToolOutcome + result::ToolExecutionResult + is_error::Bool +end + +struct FinalizedToolOutcome + tool_call::ToolCall + result::ToolExecutionResult + is_error::Bool +end + +function prepare_tool_call( + current_context::AgentContext, + assistant_message::AssistantMessage, + tool_call::ToolCall, + config::AgentLoopConfig, + signal::Union{AbortSignal, Nothing} +) + tool = find_tool(current_context.tools, tool_call.name) + + if tool === nothing + return ImmediateToolOutcome( + ToolExecutionResult([TextContent("Tool $(tool_call.name) not found")], Dict(), nothing, [], false), + true + ) + end + + try + prepared_tool_call = prepare_tool_call_arguments(tool, tool_call) + validated_args = validate_tool_arguments(tool, prepared_tool_call) + + if config.before_tool_call !== nothing + before_result = config.before_tool_call( + BeforeToolCallContext(assistant_message, tool_call, validated_args, current_context), + signal + ) + + if before_result.block + return ImmediateToolOutcome( + ToolExecutionResult([TextContent(before_result.reason)], Dict(), nothing, [], false), + true + ) + end + end + + return PreparedToolCall(tool_call, tool, validated_args) + catch error + return ImmediateToolOutcome( + ToolExecutionResult([TextContent(error.message)], Dict(), nothing, [], false), + true + ) + end +end + +function execute_prepared_tool_call( + prepared::PreparedToolCall, + signal::Union{AbortSignal, Nothing}, + emit::Function +) + update_events = Vector{Future}() + accepting_updates = true + + try + result = prepared.tool.execute( + prepared.tool_call.id, + prepared.args, + signal, + partial_result -> begin + if !accepting_updates + return + end + push!(update_events, Threads.@spawn begin + emit({ + type: "tool_execution_update", + tool_call_id: prepared.tool_call.id, + tool_name: prepared.tool_call.name, + args: prepared.tool_call.arguments, + partial_result: partial_result + }) + end) + end + ) + + accepting_updates = false + wait(update_events) + return ExecutedToolOutcome(result, false) + catch error + accepting_updates = false + wait(update_events) + return ExecutedToolOutcome( + ToolExecutionResult([TextContent(error.message)], Dict(), nothing, [], false), + true + ) + finally + accepting_updates = false + end +end + +function execute_tool_calls_parallel(...) + finalized_calls = Vector{Any}() + + # Preflight all tools sequentially + for tool_call in tool_calls + preparation = prepare_tool_call(...) + + if preparation isa ImmediateToolOutcome + push!(finalized_calls, preparation) + else + push!(finalized_calls, Threads.@async begin + executed = execute_prepared_tool_call(preparation, signal, emit) + finalize_executed_tool_call(...) + end) + end + end + + # Execute allowed tools concurrently + ordered_finalized = wait(finalized_calls) + + # Emit toolResult messages + messages = Vector{ToolResultMessage}() + for finalized in ordered_finalized + tool_result_message = create_tool_result_message(finalized) + emit({ type: "message_start", message: tool_result_message }) + emit({ type: "message_end", message: tool_result_message }) + push!(messages, tool_result_message) + end + + return { messages: messages, terminate: should_terminate_tool_batch(ordered_finalized) } +end +``` + +### Step 4: Agent Loop (`agent_loop.jl`) + +```julia +# agent_loop.jl +function run_agent_loop( + prompts::Vector{AgentMessage}, + context::AgentContext, + config::AgentLoopConfig, + emit::Function, + signal::Union{AbortSignal, Nothing}, + stream_fn::Function +) + new_messages = copy(prompts) + current_context = AgentContext( + context.system_prompt, + vcat(context.messages, prompts), + context.tools + ) + + emit({ type: "agent_start" }) + emit({ type: "turn_start" }) + + for prompt in prompts + emit({ type: "message_start", message: prompt }) + emit({ type: "message_end", message: prompt }) + end + + run_loop(current_context, new_messages, config, signal, emit, stream_fn) + + return new_messages +end + +function run_loop( + initial_context::AgentContext, + new_messages::Vector{AgentMessage}, + initial_config::AgentLoopConfig, + signal::Union{AbortSignal, Nothing}, + emit::Function, + stream_fn::Function +) + current_context = initial_context + config = initial_config + first_turn = true + pending_messages = config.get_steering_messages !== nothing ? config.get_steering_messages() : [] + + while true + has_more_tool_calls = true + + while has_more_tool_calls || !isempty(pending_messages) + if !first_turn + emit({ type: "turn_start" }) + else + first_turn = false + end + + # Process pending messages + if !isempty(pending_messages) + for msg in pending_messages + emit({ type: "message_start", message: msg }) + emit({ type: "message_end", message: msg }) + push!(current_context.messages, msg) + push!(new_messages, msg) + end + pending_messages = [] + end + + # Stream assistant response + message = stream_assistant_response(current_context, config, signal, emit, stream_fn) + push!(new_messages, message) + + # Check for errors + if message.stop_reason == "error" || message.stop_reason == "aborted" + emit({ type: "turn_end", message: message, tool_results: [] }) + emit({ type: "agent_end", messages: new_messages }) + return + end + + # Extract tool calls + tool_calls = filter(c -> c.type == "toolCall", message.content) + tool_results = [] + has_more_tool_calls = false + + if !isempty(tool_calls) + executed_batch = execute_tool_calls(current_context, message, config, signal, emit) + append!(tool_results, executed_batch.messages) + has_more_tool_calls = !executed_batch.terminate + + for result in tool_results + push!(current_context.messages, result) + push!(new_messages, result) + end + end + + emit({ type: "turn_end", message: message, tool_results: tool_results }) + + # Check prepareNextTurn hook + if config.prepare_next_turn !== nothing + next_turn_snapshot = config.prepare_next_turn({ + message: message, + tool_results: tool_results, + context: current_context, + new_messages: new_messages + }) + + if next_turn_snapshot !== nothing + current_context = next_turn_snapshot.context !== nothing ? next_turn_snapshot.context : current_context + config = merge(config, model = next_turn_snapshot.model !== nothing ? next_turn_snapshot.model : config.model) + end + end + + # Check shouldStopAfterTurn hook + if config.should_stop_after_turn !== nothing && config.should_stop_after_turn({ + message: message, + tool_results: tool_results, + context: current_context, + new_messages: new_messages + }) + emit({ type: "agent_end", messages: new_messages }) + return + end + + # Get steering messages + pending_messages = config.get_steering_messages !== nothing ? config.get_steering_messages() : [] + end + + # Check follow-up messages + follow_up_messages = config.get_follow_up_messages !== nothing ? config.get_follow_up_messages() : [] + + if !isempty(follow_up_messages) + pending_messages = follow_up_messages + continue + end + + break + end + + emit({ type: "agent_end", messages: new_messages }) +end + +function stream_assistant_response(context, config, signal, emit, stream_fn) + # 1. Transform context (optional) + messages = context.messages + if config.transform_context !== nothing + messages = config.transform_context(messages, signal) + end + + # 2. Convert to LLM format + llm_messages = config.convert_to_llm(messages) + + # 3. Build LLM context + llm_context = Context( + system_prompt = context.system_prompt, + messages = llm_messages, + tools = context.tools + ) + + # 4. Resolve API key + resolved_api_key = config.get_api_key !== nothing ? config.get_api_key(config.model.provider) : config.api_key + + # 5. Call stream function + response = stream_fn(config.model, llm_context, merge(config, api_key = resolved_api_key, signal = signal)) + + # 6. Stream events + partial_message = nothing + added_partial = false + + for event in response + if event.type == "start" + partial_message = event.partial + push!(context.messages, partial_message) + added_partial = true + emit({ type: "message_start", message: deepcopy(partial_message) }) + elseif event.type in ["text_start", "text_delta", "text_end", "thinking_start", "thinking_delta", "thinking_end", "toolcall_start", "toolcall_delta", "toolcall_end"] + if partial_message !== nothing + partial_message = event.partial + context.messages[end] = partial_message + emit({ + type: "message_update", + assistant_message_event: event, + message: deepcopy(partial_message) + }) + end + elseif event.type == "done" || event.type == "error" + final_message = response.result() + + if added_partial + context.messages[end] = final_message + else + push!(context.messages, final_message) + emit({ type: "message_start", message: deepcopy(final_message) }) + end + + emit({ type: "message_end", message: final_message }) + return final_message + end + end +end +``` + +### Step 5: Agent Class (`agent.jl`) + +```julia +# agent.jl +mutable struct ActiveRun + promise::Promise + resolve::Function + abort_controller::AbortController +end + +mutable struct Agent + _state::MutableAgentState + listeners::Set{Function} + steering_queue::PendingMessageQueue + follow_up_queue::PendingMessageQueue + convert_to_llm::Function + transform_context::Union{Function, Nothing} + stream_function::Function + get_api_key::Union{Function, Nothing} + on_payload::Union{Function, Nothing} + on_response::Union{Function, Nothing} + before_tool_call::Union{Function, Nothing} + after_tool_call::Union{Function, Nothing} + prepare_next_turn::Union{Function, Nothing} + prepare_next_turn_with_context::Union{Function, Nothing} + active_run::Union{ActiveRun, Nothing} + session_id::Union{String, Nothing} + thinking_budgets::Union{ThinkingBudgets, Nothing} + transport::Symbol + max_retry_delay_ms::Union{Int, Nothing} + tool_execution::Symbol +end + +struct MutableAgentState + system_prompt::String + model::Model + thinking_level::ThinkingLevel + tools::Vector{Tool} + messages::Vector{AgentMessage} + is_streaming::Bool + streaming_message::Union{AssistantMessage, Nothing} + pending_tool_calls::Set{String} + error_message::Union{String, Nothing} +end + +function Agent(; kwargs...) + state = MutableAgentState( + kwargs[:initial_state].system_prompt, + kwargs[:initial_state].model, + kwargs[:initial_state].thinking_level, + copy(kwargs[:initial_state].tools), + copy(kwargs[:initial_state].messages), + false, + nothing, + Set{String}(), + nothing + ) + + Agent( + state, + Set{Function}(), + PendingMessageQueue(kwargs[:steering_mode] === nothing ? "one-at-a-time" : kwargs[:steering_mode]), + PendingMessageQueue(kwargs[:follow_up_mode] === nothing ? "one-at-a-time" : kwargs[:follow_up_mode]), + kwargs[:convert_to_llm] !== nothing ? kwargs[:convert_to_llm] : default_convert_to_llm, + kwargs[:transform_context], + kwargs[:stream_fn], + kwargs[:get_api_key], + kwargs[:on_payload], + kwargs[:on_response], + kwargs[:before_tool_call], + kwargs[:after_tool_call], + kwargs[:prepare_next_turn], + kwargs[:prepare_next_turn_with_context], + nothing, + kwargs[:session_id], + kwargs[:thinking_budgets], + kwargs[:transport] === nothing ? :auto : kwargs[:transport], + kwargs[:max_retry_delay_ms], + kwargs[:tool_execution] === nothing ? :parallel : kwargs[:tool_execution] + ) +end + +function subscribe(agent::Agent, listener::Function) + push!(agent.listeners, listener) + return () -> delete!(agent.listeners, listener) +end + +function prompt(agent::Agent, input::String) + if agent.active_run !== nothing + throw(ErrorException("Agent is already processing.")) + end + + messages = normalize_prompt_input(input) + run_prompt_messages(agent, messages) +end + +function prompt(agent::Agent, messages::Vector{AgentMessage}) + if agent.active_run !== nothing + throw(ErrorException("Agent is already processing.")) + end + + run_prompt_messages(agent, messages) +end + +function continue(agent::Agent) + if agent.active_run !== nothing + throw(ErrorException("Agent is already processing.")) + end + + last_message = agent._state.messages[end] + + if last_message.message isa AssistantMessage + queued_steering = drain(agent.steering_queue) + + if !isempty(queued_steering) + run_prompt_messages(agent, queued_steering, skip_initial_steering_poll = true) + return + end + + queued_follow_ups = drain(agent.follow_up_queue) + + if !isempty(queued_follow_ups) + run_prompt_messages(agent, queued_follow_ups) + return + end + + throw(ErrorException("Cannot continue from message role: assistant")) + end + + run_continuation(agent) +end + +function run_prompt_messages(agent::Agent, messages::Vector{AgentMessage}, options = Dict()) + run_with_lifecycle(agent) do signal + run_agent_loop( + messages, + create_context_snapshot(agent), + create_loop_config(agent, options), + event -> process_events(agent, event), + signal, + agent.stream_function + ) + end +end + +function create_context_snapshot(agent::Agent) + AgentContext( + agent._state.system_prompt, + copy(agent._state.messages), + copy(agent._state.tools) + ) +end + +function create_loop_config(agent::Agent, options = Dict()) + skip_initial_steering_poll = get(options, :skip_initial_steering_poll, false) + + AgentLoopConfig( + model = agent._state.model, + reasoning = agent._state.thinking_level == THINKING_OFF ? nothing : agent._state.thinking_level, + session_id = agent.session_id, + on_payload = agent.on_payload, + on_response = agent.on_response, + transport = agent.transport, + thinking_budgets = agent.thinking_budgets, + max_retry_delay_ms = agent.max_retry_delay_ms, + tool_execution = agent.tool_execution, + before_tool_call = agent.before_tool_call, + after_tool_call = agent.after_tool_call, + prepare_next_turn = create_prepare_next_turn(agent), + convert_to_llm = agent.convert_to_llm, + transform_context = agent.transform_context, + get_api_key = agent.get_api_key, + get_steering_messages = () -> begin + if skip_initial_steering_poll + skip_initial_steering_poll = false + return [] + end + return drain(agent.steering_queue) + end, + get_follow_up_messages = () -> drain(agent.follow_up_queue) + ) +end + +function create_prepare_next_turn(agent::Agent) + function prepare(context, signal) + if agent.prepare_next_turn_with_context !== nothing + return agent.prepare_next_turn_with_context(context, signal) + end + + if agent.prepare_next_turn !== nothing + return agent.prepare_next_turn(signal) + end + + return nothing + end + + return prepare +end + +function run_with_lifecycle(agent::Agent, executor::Function) + abort_controller = AbortController() + promise = Promise{Void}() + resolve = () -> nothing + + function set_resolve(value) + resolve = value + if !isready(promise) + put!(promise, nothing) + end + end + + agent.active_run = ActiveRun(promise, set_resolve, abort_controller) + + agent._state.is_streaming = true + agent._state.streaming_message = nothing + agent._state.error_message = nothing + + try + executor(abort_controller.signal) + catch error + handle_run_failure(agent, error, abort_controller.signal.aborted) + finally + finish_run(agent) + end +end + +function handle_run_failure(agent::Agent, error, aborted) + failure_message = AssistantMessage( + [TextContent("")], + agent._state.model.api, + agent._state.model.provider, + agent._state.model.id, + EMPTY_USAGE, + aborted ? "aborted" : "error", + error isa ErrorException ? error.message : string(error), + Dates.datetime2timestamp(Dates.now()) + ) + + process_events(agent, { type: "message_start", message: failure_message }) + process_events(agent, { type: "message_end", message: failure_message }) + process_events(agent, { type: "turn_end", message: failure_message, tool_results: [] }) + process_events(agent, { type: "agent_end", messages: [failure_message] }) +end + +function finish_run(agent::Agent) + agent._state.is_streaming = false + agent._state.streaming_message = nothing + agent._state.pending_tool_calls = Set{String}() + + if agent.active_run !== nothing + agent.active_run.resolve() + agent.active_run = nothing + end +end + +function process_events(agent::Agent, event) + if event.type == "message_start" + agent._state.streaming_message = event.message + elseif event.type == "message_update" + agent._state.streaming_message = event.message + elseif event.type == "message_end" + agent._state.streaming_message = nothing + push!(agent._state.messages, event.message) + elseif event.type == "tool_execution_start" + push!(agent._state.pending_tool_calls, event.tool_call_id) + elseif event.type == "tool_execution_end" + delete!(agent._state.pending_tool_calls, event.tool_call_id) + elseif event.type == "turn_end" + if event.message.message isa AssistantMessage && event.message.message.error_message !== nothing + agent._state.error_message = event.message.message.error_message + end + elseif event.type == "agent_end" + agent._state.streaming_message = nothing + end + + signal = agent.active_run !== nothing ? agent.active_run.abort_controller.signal : nothing + + for listener in agent.listeners + Threads.@spawn listener(event, signal) + end +end + +function normalize_prompt_input(input::String) + content = [TextContent(input)] + return [AgentMessage(UserMessage(content, Dates.datetime2timestamp(Dates.now())))] +end + +function normalize_prompt_input(messages::Vector{AgentMessage}) + return messages +end +``` + +### Step 6: Session Persistence (`session.jl`) + +```julia +# session.jl +struct SessionEntry + type::String + id::String + parent_id::Union{String, Nothing} + timestamp::Int64 + # Dynamic fields based on type +end + +struct MessageEntry <: SessionEntry + message::AgentMessage +end + +struct CompactionEntry <: SessionEntry + summary::String + first_kept_entry_id::Union{String, Nothing} + tokens_before::Int +end + +struct BranchSummaryEntry <: SessionEntry + summary::String + from_id::String +end + +struct LeafEntry <: SessionEntry + target_id::String +end + +struct SessionStorage{TMetadata} + file_path::String + metadata::TMetadata + entries::Dict{String, SessionEntry} + leaf_id::Union{String, Nothing} +end + +function create_session(file_system::FileSystem, cwd::String, id::String, timestamp::DateTime) + session_dir = join_path(file_system.sessions_root, encode_cwd(cwd)) + create_dir(session_dir, recursive = true) + + file_path = join_path(session_dir, "$(replace(timestamp, r"[:\.]" => "-"))_$id.jsonl") + + metadata = SessionMetadata( + path = file_path, + cwd = cwd, + session_id = id, + parent_session_path = nothing, + created_at = timestamp + ) + + storage = SessionStorage(file_path, metadata, Dict{String, SessionEntry}(), nothing) + + # Write header + header = Dict( + "type" => "session", + "version" => 3, + "id" => id, + "timestamp" => string(timestamp), + "cwd" => cwd, + "metadata" => Dict{String, Any}() + ) + + open(file_path, "w") do f + write(f, JSON.json(header)) + write(f, "\n") + end + + return storage +end + +function append_message(storage::SessionStorage, message::AgentMessage) + entry_id = uuid7() + timestamp = Dates.datetime2timestamp(Dates.now()) + + entry = Dict( + "type" => "message", + "id" => entry_id, + "parentId" => storage.leaf_id, + "timestamp" => string(DateTime(timestamp)), + "message" => message_to_dict(message) + ) + + open(storage.file_path, "a") do f + write(f, JSON.json(entry)) + write(f, "\n") + end + + storage.entries[entry_id] = entry + storage.leaf_id = entry_id +end + +function append_compaction(storage::SessionStorage, summary::String, first_kept_entry_id::String, tokens_before::Int) + entry_id = uuid7() + timestamp = Dates.datetime2timestamp(Dates.now()) + + entry = Dict( + "type" => "compaction", + "id" => entry_id, + "parentId" => storage.leaf_id, + "timestamp" => string(DateTime(timestamp)), + "summary" => summary, + "firstKeptEntryId" => first_kept_entry_id, + "tokensBefore" => tokens_before + ) + + open(storage.file_path, "a") do f + write(f, JSON.json(entry)) + write(f, "\n") + end + + storage.entries[entry_id] = entry + storage.leaf_id = entry_id +end + +function get_branch(storage::SessionStorage, from_id::Union{String, Nothing} = nothing) + leaf_id = from_id !== nothing ? from_id : storage.leaf_id + + if leaf_id === nothing + return [] + end + + entries = Vector{SessionEntry}() + current_id = leaf_id + + while current_id !== nothing + entry = get(storage.entries, current_id, nothing) + + if entry === nothing + break + end + + push!(entries, entry) + + if entry.type == "compaction" && entry.first_kept_entry_id !== nothing + current_id = entry.first_kept_entry_id + else + current_id = entry.parent_id + end + end + + return reverse(entries) +end +``` + +### Step 7: Compaction (`compaction.jl`) + +```julia +# compaction.jl +const DEFAULT_COMPACTION_SETTINGS = Dict( + :enabled => true, + :reserve_tokens => 16384, + :keep_recent_tokens => 20000 +) + +function estimate_tokens(message::AgentMessage) + if message.message isa UserMessage + return length(join(message.message.content)) / 4 + elseif message.message isa AssistantMessage + total = 0 + for block in message.message.content + if block.type == "text" + total += length(block.text) + elseif block.type == "thinking" + total += length(block.thinking) + elseif block.type == "toolCall" + total += length(block.name) + length(JSON.json(block.arguments)) + end + end + return total + elseif message.message isa ToolResultMessage + return length(join(message.message.content)) / 4 + elseif message.message isa CustomMessage + if message.message.content isa String + return length(message.message.content) / 4 + else + return length(join(message.message.content)) / 4 + end + else + return 0 + end +end + +function estimate_context_tokens(messages::Vector{AgentMessage}) + total = 0 + for message in messages + total += estimate_tokens(message) + end + return total +end + +function should_compact(context_tokens::Int, context_window::Int, settings::Dict) + return context_tokens > context_window - get(settings, :reserve_tokens, 16384) +end + +function find_cut_point(entries::Vector{SessionEntry}, keep_recent_tokens::Int) + accumulated = 0 + + for i = length(entries):-1:1 + entry = entries[i] + + # Skip invalid cut points + if entry.type == "message" && entry.message.message isa ToolResultMessage + continue + end + + tokens = estimate_context_tokens([entry.message]) + accumulated += tokens + + if accumulated >= keep_recent_tokens + return i + 1 + end + end + + return 1 +end + +function prepare_compaction(branch_entries::Vector{SessionEntry}, settings::Dict) + # Find previous compaction + previous_compaction = nothing + for entry in branch_entries + if entry.type == "compaction" + previous_compaction = entry + end + end + + # Estimate tokens + context_tokens = estimate_context_tokens([e.message for e in branch_entries]) + + # Find cut point + cut_point = find_cut_point(branch_entries, get(settings, :keep_recent_tokens, 20000)) + + # Split into groups + messages_to_summarize = branch_entries[1:cut_point] + retained_tail = branch_entries[cut_point:end] + + # Extract file operations + file_ops = extract_file_operations(messages_to_summarize) + + return Dict( + :previous_compaction => previous_compaction, + :context_tokens => context_tokens, + :cut_point => cut_point, + :messages_to_summarize => messages_to_summarize, + :retained_tail => retained_tail, + :file_ops => file_ops + ) +end + +function generate_summary(messages::Vector{AgentMessage}, previous_summary::Union{String, Nothing} = nothing) + conversation = serialize_conversation(messages) + + if previous_summary !== nothing + # Update prompt + prompt = """ + + $previous_summary + + + + $conversation + + + Update the previous summary with new progress. + """ + else + # Fresh prompt + prompt = """ + + $conversation + + + Generate a summary: + ## Goal + ## Constraints & Preferences + ## Progress + ### Done + ### In Progress + ### Blocked + ## Key Decisions + ## Next Steps + ## Critical Context + ## Files read: [...] + ## Files modified: [...] + """ + end + + # Call LLM + response = models.complete_simple( + model, + Context( + system_prompt = "You are a helpful assistant that summarizes conversations.", + messages = [UserMessage([TextContent(prompt)], Dates.datetime2timestamp(Dates.now()))], + tools = nothing + ) + ) + + return response.choices[1].message.content +end + +function compact(preparation::Dict, model::Model, models::Models) + messages_to_summarize = preparation[:messages_to_summarize] + previous_compaction = preparation[:previous_compaction] + + if previous_compaction !== nothing + previous_summary = previous_compaction.summary + summary = generate_summary(messages_to_summarize, previous_summary) + else + summary = generate_summary(messages_to_summarize, nothing) + end + + # Extract file operations + file_ops = preparation[:file_ops] + summary *= "\n\nFiles read: $(file_ops.read_files)" + summary *= "\nFiles modified: $(file_ops.modified_files)" + + return Dict( + :summary => summary, + :first_kept_entry_id => messages_to_summarize[end].id, + :tokens_before => preparation[:context_tokens], + :retained_tail => preparation[:retained_tail] + ) +end +``` + +### Step 8: Event System (`events.jl`) + +```julia +# events.jl +struct AgentEvent + type::String + # Dynamic fields based on type +end + +struct AgentStart <: AgentEvent + type::String = "agent_start" +end + +struct AgentEnd <: AgentEvent + type::String = "agent_end" + messages::Vector{AgentMessage} +end + +struct TurnStart <: AgentEvent + type::String = "turn_start" +end + +struct TurnEnd <: AgentEvent + type::String = "turn_end" + message::AgentMessage + tool_results::Vector{ToolResultMessage} +end + +struct MessageStart <: AgentEvent + type::String = "message_start" + message::AgentMessage +end + +struct MessageUpdate <: AgentEvent + type::String = "message_update" + message::AgentMessage + assistant_message_event::Any +end + +struct MessageEnd <: AgentEvent + type::String = "message_end" + message::AgentMessage +end + +struct ToolExecutionStart <: AgentEvent + type::String = "tool_execution_start" + tool_call_id::String + tool_name::String + args::Any +end + +struct ToolExecutionUpdate <: AgentEvent + type::String = "tool_execution_update" + tool_call_id::String + tool_name::String + args::Any + partial_result::Any +end + +struct ToolExecutionEnd <: AgentEvent + type::String = "tool_execution_end" + tool_call_id::String + tool_name::String + result::Any + is_error::Bool +end + +function create_tool_result_message(finalized) + ToolResultMessage( + finalized.tool_call.id, + finalized.tool_call.name, + finalized.result.content, + finalized.result.details, + finalized.result.usage, + finalized.is_error, + Dates.datetime2timestamp(Dates.now()) + ) +end +``` + +### Step 9: Hook System (`hooks.jl`) + +```julia +# hooks.jl +struct BeforeToolCallContext + assistant_message::AssistantMessage + tool_call::ToolCall + args::Any + context::AgentContext +end + +struct BeforeToolCallResult + block::Bool + reason::Union{String, Nothing} +end + +struct AfterToolCallContext + assistant_message::AssistantMessage + tool_call::ToolCall + args::Any + result::ToolExecutionResult + is_error::Bool + context::AgentContext +end + +struct AfterToolCallResult + content::Union{Vector{Union{TextContent, ImageContent}}, Nothing} + details::Union{Any, Nothing} + is_error::Union{Bool, Nothing} + usage::Union{Usage, Nothing} + terminate::Union{Bool, Nothing} +end + +struct PrepareNextTurnContext + message::AssistantMessage + tool_results::Vector{ToolResultMessage} + context::AgentContext + new_messages::Vector{AgentMessage} +end + +struct AgentLoopTurnUpdate + context::Union{AgentContext, Nothing} + model::Union{Model, Nothing} + thinking_level::Union{ThinkingLevel, Nothing} +end + +function default_before_tool_call(context::BeforeToolCallContext, signal::Union{AbortSignal, Nothing}) + return nothing +end + +function default_after_tool_call(context::AfterToolCallContext, signal::Union{AbortSignal, Nothing}) + return AfterToolCallResult(nothing, nothing, nothing, nothing, nothing) +end + +function default_prepare_next_turn(context::PrepareNextTurnContext) + return nothing +end +``` + +### Step 10: Stream Utilities (`stream.jl`) + +```julia +# stream.jl +struct StreamEvent + type::String + # Dynamic fields based on type +end + +struct StartEvent <: StreamEvent + type::String = "start" + partial::AssistantMessage +end + +struct TextStartEvent <: StreamEvent + type::String = "text_start" +end + +struct TextDeltaEvent <: StreamEvent + type::String = "text_delta" + delta::String + partial::AssistantMessage +end + +struct TextEndEvent <: StreamEvent + type::String = "text_end" +end + +struct ThinkingStartEvent <: StreamEvent + type::String = "thinking_start" +end + +struct ThinkingDeltaEvent <: StreamEvent + type::String = "thinking_delta" + delta::String + partial::AssistantMessage +end + +struct ThinkingEndEvent <: StreamEvent + type::String = "thinking_end" +end + +struct ToolcallStartEvent <: StreamEvent + type::String = "toolcall_start" +end + +struct ToolcallDeltaEvent <: StreamEvent + type::String = "toolcall_delta" + delta::String + partial::AssistantMessage +end + +struct ToolcallEndEvent <: StreamEvent + type::String = "toolcall_end" +end + +struct DoneEvent <: StreamEvent + type::String = "done" +end + +struct ErrorEvent <: StreamEvent + type::String = "error" + error::String +end + +struct Stream + events::Channel{StreamEvent} +end + +function Stream() + return Stream(Channel{StreamEvent}(32)) +end + +function stream_simple(model::Model, context::Context, options::Dict) + stream = Stream() + + Threads.@spawn begin + # Call LLM provider + response = make_llm_call(model, context, options) + + # Stream events + for chunk in response + if chunk.delta !== nothing + push!(stream.events, TextDeltaEvent(chunk.delta, chunk.message)) + end + + if chunk.tool_calls !== nothing + for tool_call in chunk.tool_calls + push!(stream.events, ToolcallDeltaEvent(JSON.json(tool_call), chunk.message)) + end + end + end + + push!(stream.events, DoneEvent()) + close(stream.events) + end + + return stream +end + +function next_event(stream::Stream) + return take!(stream.events) +end + +function isdone(stream::Stream) + return isclosed(stream.events) +end +``` + +### Usage Example + +```julia +# main.jl +using PiAgent + +# Create models +models = create_models() +models.set_provider(anthropic_provider()) +model = models.get_model("anthropic", "claude-sonnet-4-6") + +# Create agent +agent = Agent( + initial_state = AgentState( + system_prompt = "You are a helpful assistant.", + model = model, + thinking_level = THINKING_OFF, + tools = [], + messages = [] + ), + stream_fn = models.stream_simple, + before_tool_call = default_before_tool_call, + after_tool_call = default_after_tool_call +) + +# Subscribe to events +unsubscribe = agent.subscribe() do event, signal + if event.type == "message_update" && event.assistant_message_event.type == "text_delta" + print(event.assistant_message_event.delta) + end +end + +# Run agent +prompt(agent, "Hello!") + +# Clean up +unsubscribe() +``` + +--- + +## Data Flow Diagrams + +### Complete Prompt Flow + +``` +User: "What product do you have in stock?" + +1. normalize_prompt_input() + ↓ + [{ role: "user", content: [{ type: "text", text: "..." }], timestamp: ... }] + +2. run_prompt_messages() + ↓ + create_context_snapshot() + create_loop_config() + +3. run_agent_loop() + ↓ + emit(agent_start) + emit(turn_start) + emit(message_start, message_end) for user prompt + +4. run_loop() + ↓ + stream_assistant_response() + ↓ + transform_context() (optional) + ↓ + convert_to_llm() + ↓ + Build LLM context + ↓ + stream_function() + ↓ + LLM API call + +5. Stream events from LLM + ↓ + start → text_delta* → done + +6. Commit message to context + ↓ + emit(message_start, message_update*, message_end) + +7. Extract tool calls + ↓ + execute_tool_calls() + ↓ + prepare_tool_call() for each + ↓ + execute_prepared_tool_call() + ↓ + finalize_executed_tool_call() + ↓ + create_tool_result_message() + ↓ + emit(tool_execution_start, tool_execution_end) + emit(message_start, message_end) for tool result + +8. emit(turn_end) + ↓ + prepareNextTurn hook + ↓ + shouldStopAfterTurn hook + +9. Check steering/follow-up queues + ↓ + Continue loop or exit + +10. emit(agent_end) +``` + +### Tool Execution Flow + +``` +Assistant Message (with toolCall blocks) + ↓ +extract tool calls + ↓ +determine execution mode + ↓ +for each tool_call: + ↓ + prepare_tool_call() + ├─ find_tool(tool_call.name) + ├─ prepare_tool_call_arguments() + ├─ validate_tool_arguments() + ├─ before_tool_call hook + │ └─ block? → error result + └─ return PreparedToolCall + ↓ + execute_prepared_tool_call() + ├─ tool.execute() + ├─ stream partial results via onUpdate() + └─ return ExecutedToolCallOutcome + ↓ + finalize_executed_tool_call() + ├─ after_tool_call hook + └─ return FinalizedToolCallOutcome + ↓ + create_tool_result_message() + ↓ + emit(tool_execution_start/end) + emit(message_start/end) + ↓ + add to context.messages +``` + +### Session Persistence Flow + +``` +AgentEvent + ↓ +handle_agent_event() + ↓ +pendingSessionWrites.push() + ↓ +flush_pending_session_writes() + ↓ +for each pending write: + ├─ message → session.append_message() + ├─ compaction → session.append_compaction() + ├─ branch_summary → session.append_branch_summary() + ├─ leaf → session.set_leaf_id() + └─ custom → session.append_custom_entry() + ↓ +JSONL file update +``` + +--- + +## Key Algorithms + +### 1. Context Token Estimation + +```typescript +function estimateContextTokens(messages) { + let total = 0 + for (const message of messages) { + total += estimateTokens(message) + } + return total +} + +function estimateTokens(message) { + switch (message.role) { + case "user": + return message.content.reduce((sum, c) => sum + c.text.length, 0) / 4 + + case "assistant": + return message.content.reduce((sum, c) => { + if (c.type === "text") return sum + c.text.length + if (c.type === "thinking") return sum + c.thinking.length + if (c.type === "toolCall") return sum + c.name.length + JSON.stringify(c.arguments).length + return sum + }, 0) + + case "toolResult": + case "custom": + return message.content.reduce((sum, c) => sum + c.text.length, 0) / 4 + + case "bashExecution": + return (message.command.length + message.output.length) / 4 + + case "compactionSummary": + case "branchSummary": + return message.summary.length / 4 + } +} +``` + +### 2. Turn Start Index Detection + +```typescript +function findTurnStartIndex(entries, startIndex, endIndex) { + // Find first message where role is user or assistant + for (let i = endIndex - 1; i >= startIndex; i--) { + const entry = entries[i] + if (entry.type === "message") { + const role = entry.message.role + if (role === "user" || role === "assistant") { + return i + } + } + } + return endIndex +} +``` + +### 3. File Operations Extraction + +```typescript +function extractFileOpsFromMessage(message, fileOps) { + if (message.role === "toolResult") { + const details = message.details + if (details) { + if (details.readFiles) { + for (const f of details.readFiles) fileOps.read.add(f) + } + if (details.modifiedFiles) { + for (const f of details.modifiedFiles) fileOps.edited.add(f) + } + } + } +} + +function createFileOps() { + return { + read: new Set(), + edited: new Set() + } +} + +function computeFileLists(fileOps, messages, entries, prevCompactionIndex) { + return { + readFiles: [...fileOps.read], + modifiedFiles: [...fileOps.edited] + } +} +``` + +### 4. Branch Context Building + +```typescript +function buildContextEntries(pathEntries, options) { + let entries = defaultContextEntryTransform(pathEntries) + for (const transform of options.entryTransforms ?? []) { + entries = transform(entries) + } + return entries +} + +function defaultContextEntryTransform(pathEntries) { + let compaction = null + for (const entry of pathEntries) { + if (entry.type === "compaction") { + compaction = entry + } + } + + if (!compaction) { + return [...pathEntries] + } + + const entries = [compaction] + const compactionIdx = pathEntries.findIndex(e => e.id === compaction.id) + + if (compaction.retainedTail) { + for (let i = compactionIdx + 1; i < pathEntries.length; i++) { + entries.push(pathEntries[i]) + } + return entries + } + + if (compaction.firstKeptEntryId) { + let foundFirstKept = false + for (let i = 0; i < compactionIdx; i++) { + const entry = pathEntries[i] + if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true + if (foundFirstKept) entries.push(entry) + } + } + + for (let i = compactionIdx + 1; i < pathEntries.length; i++) { + entries.push(pathEntries[i]) + } + + return entries +} + +function sessionEntryToContextMessages(entry, index, entries, options) { + if (entry.type === "message") { + return [entry.message] + } + if (entry.type === "compaction") { + return [ + createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp), + ...(entry.retainedTail ?? []) + ] + } + if (entry.type === "branchSummary" && entry.summary) { + return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)] + } + if (entry.type === "custom") { + return [...(options.entryProjectors?.[entry.customType]?.(entry, index, entries) ?? [])] + } + return [] +} +``` + +### 5. JSONL File Format + +```typescript +// Header +{ + type: "session", + version: 3, + id: sessionId, + timestamp: createdAt.toISOString(), + cwd: cwd, + parentSessionPath: parentSessionPath, + metadata: metadata +} + +// Entry types +{ + type: "message", + id: uuid7(), + parentId: previousLeafId, + timestamp: now.toISOString(), + message: { + role: "user" | "assistant" | "toolResult" | "custom", + // ... message fields + } +} + +{ + type: "compaction", + id: uuid7(), + parentId: previousLeafId, + timestamp: now.toISOString(), + summary: "...", + firstKeptEntryId: entryId, + tokensBefore: tokenCount, + details: { + readFiles: [...], + modifiedFiles: [...] + } +} + +{ + type: "branch_summary", + id: uuid7(), + parentId: previousLeafId, + timestamp: now.toISOString(), + summary: "...", + fromId: branchStartId +} + +{ + type: "leaf", + id: uuid7(), + parentId: previousLeafId, + timestamp: now.toISOString(), + targetId: newLeafId +} +``` + +### 6. Tool Call Argument Preparation + +```typescript +function prepareToolCallArguments(tool, toolCall) { + if (!tool.prepareArguments) { + return toolCall + } + + const preparedArguments = tool.prepareArguments(toolCall.arguments) + if (preparedArguments === toolCall.arguments) { + return toolCall + } + + return { + ...toolCall, + arguments: preparedArguments + } +} +``` + +### 7. Tool Batch Termination Check + +```typescript +function shouldTerminateToolBatch(finalizedCalls) { + return finalizedCalls.length > 0 && + finalizedCalls.every(finalized => + finalized.result.terminate === true + ) +} +``` + +### 8. Message Normalization + +```typescript +function normalizePromptInput(input, images) { + if (Array.isArray(input)) { + return input + } + + if (typeof input !== "string") { + return [input] + } + + const content = [{ type: "text", text: input }] + if (images && images.length > 0) { + content.push(...images) + } + + return [{ role: "user", content, timestamp: Date.now() }] +} +``` + +--- + +## Summary + +The Pi Agent Core architecture is a sophisticated stateful agent system with: + +1. **Stateful execution**: Maintains conversation history across multiple turns +2. **Tool execution**: Supports LLM tool calling with parallel/sequential modes +3. **Event streaming**: Real-time event system for UI updates +4. **Session persistence**: JSONL-based persistent storage with tree-structured branching +5. **Memory compaction**: Automatic context window management through LLM summarization +6. **Flexible extension**: Hook-based customization at every system boundary + +The implementation follows these key principles: + +- **Separation of concerns**: Core agent logic separated from storage and provider implementations +- **Streaming first**: All operations designed around async streams for responsiveness +- **Type safety**: Strong types for compile-time guarantees +- **Extensibility**: Hooks at every major boundary allow customization +- **Persistence**: Session history survives restarts through JSONL files + +The Julia implementation should mirror this architecture, using Julia's type system for strong typing, async/await for streaming, and JSON for persistence. diff --git a/learn_pi_agent/memory_management.md b/learn_pi_agent/memory_management.md new file mode 100644 index 0000000..6c34b2f --- /dev/null +++ b/learn_pi_agent/memory_management.md @@ -0,0 +1,624 @@ +# Memory & Context Management in Pi Agent + +## 1. Architecture Overview + +The agent manages memory at **two layers**: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Agent Harness │ +│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────────┐ │ +│ │ Session │ │ Compaction │ │ Branch Navigation │ │ +│ │ (JSONL tree)│ │ (summarize) │ │ (reset + summarize)│ │ +│ └──────┬───────┘ └──────────────┘ └─────────────────────┘ │ +└─────────┼───────────────────────────────────────────────────────┘ + │ builds context +┌─────────▼───────────────────────────────────────────────────────┐ +│ Agent Class │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ _state.messages: AgentMessage[] (linear transcript) │ │ +│ │ _state.tools, systemPrompt, model │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ subscribe() → events → UI updates │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Key separation:** +- **In-memory** (`Agent`): linear transcript for the current run. Cleared on `reset()`. +- **On-disk** (`Session`): persistent tree of entries in JSONL files. Survives restarts. +- **Compaction**: replaces old on-disk history with an LLM-generated summary, controlling context window usage. + +--- + +## 2. Data Flow: From Prompt to LLM Call + +``` +agent.prompt("Read README.md") + │ + ▼ +┌──────────────────────────┐ +│ normalizePromptInput() │ → { role: "user", content: "..." } +└──────────┬───────────────┘ + │ + ▼ +┌──────────────────────────┐ +│ runWithLifecycle() │ → sets isStreaming=true +│ runAgentLoop() │ +└──────────┬───────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ runLoop() — the main while(true) loop │ +│ │ +│ Inner loop: │ +│ 1. Inject steering/follow-up messages │ +│ 2. streamAssistantResponse() │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ transformContext() │ │ +│ │ AgentMessage[] → AgentMessage[] │ │ +│ │ (prune, inject external context) │ │ +│ └──────────────┬──────────────────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ convertToLlm() │ │ +│ │ AgentMessage[] → Message[] │ │ +│ │ (filter to user/assistant/toolResult only) │ │ +│ └──────────────┬──────────────────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ streamFunction() → LLM provider │ │ +│ │ { systemPrompt, messages, tools } │ │ +│ └──────────────┬──────────────────────────────┘ │ +│ ▼ │ +│ Stream events: start → delta* → done │ +│ ▼ │ +│ Return AssistantMessage │ +│ 3. Extract toolCall blocks │ +│ 4. If toolCalls: executeToolCalls() │ +│ → create toolResult messages │ +│ → append to context │ +│ 5. Check shouldStopAfterTurn / prepareNextTurn │ +│ 6. Check steering/follow-up queues │ +│ → Loop if more tool calls or queued messages │ +│ │ +│ Outer loop: │ +│ → Check follow-up queue for messages after agent would │ +│ stop │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. Context Building (On-Disk → In-Memory) + +The `AgentHarness` bridges on-disk session data to the in-memory agent loop. + +``` +session.buildContext() + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ getBranch() — walk from leaf → root via parentId │ +│ │ +│ Tree structure: │ +│ │ +│ ┌──────┐ ┌──────┐ ┌──────────┐ ┌──────────┐ │ +│ │msg 1 │───▶│msg 2 │───▶│ msg 3 │───▶│ msg 4 │ │ +│ │user │ │assist│ │ toolCall │ │ user │ │ +│ └──────┘ └──────┘ └──────────┘ └──────────┘ │ +│ │ +│ Path to root: [msg1, msg2, msg3, msg4] │ +└──────────┬────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ defaultContextEntryTransform() — THE KEY STEP │ +│ │ +│ Finds latest "compaction" entry in path: │ +│ │ +│ ┌──────┐ ┌──────────┐ ┌──────┐ ┌──────┐ │ +│ │msg 1 │ │compaction│ │msg 3 │ │msg 4 │ │ +│ │user │ │summary X │ │msg 2 │ │assist│ │ +│ └──────┘ └──────────┘ └──────┘ └──────┘ │ +│ │ │ │ │ │ +│ ├──────────────┤ │ │ │ +│ │ SKIPPED │ │ │ │ +│ │ (summarized)│ │ │ │ +│ └──────────────┼───────────┘ │ │ +│ ▼ ▼ │ +│ Include compaction entry Include entries after │ +│ + firstKeptEntryId the compaction point │ +│ │ +│ Result: [compaction, msg3, msg4] │ +│ → compaction entry becomes a "compactionSummary" message │ +└──────────┬────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ sessionEntryToContextMessages() │ +│ │ +│ For each entry: │ +│ message → [message] │ +│ compaction → [compactionSummary, ...retainedTail] │ +│ branch_summary → [branchSummaryMessage] │ +│ custom_message → [customMessage] │ +│ other → [] (omitted from LLM context) │ +│ │ +│ Flat result: [compactionSummary, msg3, msg4] │ +└──────────┬────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ deriveSessionContextState() │ +│ │ +│ Extracts from entries: │ +│ thinkingLevel ← latest thinking_level_change or assistant │ +│ model ← latest model_change or assistant │ +│ activeTools ← latest active_tools_change │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. Token Estimation + +Before compaction can decide whether to trigger, it needs to know how many tokens the context uses. + +``` +estimateContextTokens(messages) + │ + ├── Has provider-reported usage on last assistant msg? + │ ├── YES → use actual usage + estimate tail + │ │ (accurate — avoids compounding error) + │ │ + │ └── NO → estimate all messages from scratch + │ + ▼ +estimateTokens(message) [character heuristic: chars / 4] + │ + ├── role === "user" + │ content.length / 4 + │ (images ≈ 4800 chars each) + │ + ├── role === "assistant" + │ sum of all content blocks: + │ text blocks → text.length + │ thinking blocks → thinking.length + │ toolCall blocks → name.length + JSON.stringify(args).length + │ + ├── role === "toolResult" / "custom" + │ content.length / 4 + │ + ├── role === "bashExecution" + │ (command.length + output.length) / 4 + │ + └── role === "compactionSummary" / "branchSummary" + summary.length / 4 +``` + +**Why `chars / 4`?** Rough heuristic: ~4 ASCII characters ≈ 1 token. Conservative estimate to avoid under-counting. + +--- + +## 5. Compaction — The Core Memory Management + +### 5.1 Trigger Condition + +``` +shouldCompact(contextTokens, contextWindow, settings) + → contextTokens > contextWindow - reserveTokens + +Defaults: + reserveTokens: 16384 (~16K tokens for summary prompt + output) + keepRecentTokens: 20000 (~20K tokens of recent history to keep) + +Example (Claude with 200K context window): + Triggers when: contextTokens > 200000 - 16384 = 183616 +``` + +### 5.2 Finding the Cut Point + +``` +findCutPoint(entries, startIndex, endIndex, keepRecentTokens) + │ + │ Walk BACKWARD from endIndex + │ + ├── Accumulate estimated tokens per message + ├── Stop when accumulated ≥ keepRecentTokens + ├── Snap to nearest valid cut point + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Valid cut points (safe to split): │ +│ - user message │ +│ - assistant message │ +│ - custom message │ +│ - branch_summary │ +│ │ +│ NOT valid (tool results stay with their call): │ +│ - toolResult message (skipped) │ +│ │ +│ Example: │ +│ │ +│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ +│ │ u1 │ │ a1 │ │ tr1│ │ u2 │ │ a2 │ │ tr2│ │ u3 │ │ +│ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ │ +│ ▲ ▲ ▲ │ +│ │ │ │ │ +│ └── kept └── cut └── discarded │ +│ (~20K tokens) point history │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 5.3 The Compaction Process + +``` +prepareCompaction(branchEntries, settings) + │ + ├── Find previous compaction (if any) → previousSummary + ├── Estimate tokens of current context + ├── findCutPoint() → firstKeptEntryId + │ + ├── Split into 3 groups: + │ │ + │ ├── messagesToSummarize: entries BEFORE cut point + │ │ (these become the summary) + │ │ + │ ├── retainedTail: entries AFTER cut point + │ │ (these stay verbatim) + │ │ + │ └── turnPrefixMessages: if cut splits a turn + │ (the beginning of an interrupted turn) + │ + └── Extract file operations from messagesToSummarize: + → readFiles, modifiedFiles + +compact(preparation, model, models) + │ + ├── If isSplitTurn: + │ ├── generateSummary(messagesToSummarize) → history summary + │ ├── generateTurnPrefixSummary(turnPrefixMessages) → turn context + │ └── Combine: history + "---" + turn prefix + │ + ├── Else (normal): + │ ├── Has previousSummary? + │ │ ├── YES → UPDATE_SUMMARIZATION_PROMPT (iterative) + │ │ └── NO → SUMMARIZATION_PROMPT (fresh) + │ └── Call LLM with conversation text + prompt + │ + ├── Append file operations: + │ "Files read: [...]\nFiles modified: [...]" + │ + └── Return: + { + summary: "## Goal...\n## Progress...\n...", + firstKeptEntryId: "entry-uuid", + tokensBefore: 185000, + retainedTail: [msg3, msg4, ...], + details: { readFiles: [...], modifiedFiles: [...] } + } +``` + +### 5.4 Summary Format + +``` +The LLM generates a structured summary: + +## Goal +- [What is the user trying to accomplish?] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements] + +## Progress +### Done +- [x] [Completed tasks] +### In Progress +- [ ] [Current work] +### Blocked +- [Issues preventing progress] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [Ordered list of what should happen next] + +## Critical Context +- [Any data, examples, or references needed to continue] + +Files read: [src/index.ts, package.json] +Files modified: [src/index.ts] +``` + +### 5.5 Iterative Compaction + +Successive compact calls **update** the existing summary rather than replacing it: + +``` +Compaction 1 (at ~185K tokens): + Summary: "## Goal: Build a login page..." + firstKeptEntryId: "entry-003" + +Compaction 2 (at ~185K tokens again): + previousSummary: "## Goal: Build a login page..." + → UPDATE_SUMMARIZATION_PROMPT + → PRESERVES existing information + → ADDS new progress (move "In Progress" → "Done") + → NEW summary: "## Goal: Build a login page... Add OAuth..." + firstKeptEntryId: "entry-003" (same boundary) +``` + +--- + +## 6. Session Storage — JSONL Format + +``` +Session file: .pi/sessions/--home-user--/2024-01-15T10-30-00_abc123.jsonl + +Line 1 (header): + {"type":"session","version":3,"id":"abc123","timestamp":"2024-01-15T10:30:00.000Z", + "cwd":"/home/user/project","parentSession":"...","metadata":{}} + +Line 2+ (entries, one per line): + {"type":"message","id":"e001","parentId":null,"timestamp":"...","message":{...}} + {"type":"message","id":"e002","parentId":"e001","timestamp":"...","message":{...}} + {"type":"compaction","id":"e003","parentId":"e002","timestamp":"...", + "summary":"## Goal: ...\n...","firstKeptEntryId":"e001", + "tokensBefore":185000} + {"type":"leaf","id":"e004","parentId":"e003","timestamp":"...", + "targetId":"e002"} +``` + +### Entry Types + +| Type | LLM Context? | Purpose | +|------|-------------|---------| +| `message` | Yes | User, assistant, toolResult | +| `compaction` | Yes (as summary message) | Replaces compacted history | +| `branch_summary` | Yes (as summary message) | Summary of diverged branch | +| `leaf` | No | Points to current tree leaf | +| `thinking_level_change` | No | Tracking thinking level changes | +| `model_change` | No | Tracking model changes | +| `active_tools_change` | No | Tracking tool enable/disable | +| `custom` | No (unless projector configured) | App-defined data | +| `custom_message` | Yes | App-defined messages | +| `label` | No | Human-readable labels | +| `session_info` | No | Session name history | + +--- + +## 7. Session Tree (Branching) + +Sessions form a **tree**, not a linear log. This lets users "go back" and try a different approach. + +``` +Session tree: + + ┌───[e01]───┐ + │ user: "a" │ + └─────┬──────┘ + ▼ + ┌──────────┐ + │ assist 1 │ + └─────┬────┘ + ▼ + ┌──────────┐ + │ toolCall │ + └─────┬────┘ + ▼ + ┌──────────┐ + │ toolRes 1│ + └─────┬────┘ + ▼ + ┌──────────┐ + │ user: "b"│ ← user goes back here + └─────┬────┘ + │ + ┌─────┴─────┐ + │ │ + ┌──────────┐ ┌──────────┐ + │ user: "c" │ │ user: "d" │ ← branch point + └────┬─────┘ └────┬─────┘ + │ │ + ┌────▼─────┐ ┌────▼─────┐ + │ assist 2 │ │ assist 3 │ ← current leaf (d) + └──────────┘ └──────────┘ + +When user navigates to "user: b": + - Leaf moves from "d" back to "b" + - Branch summary generated for diverged work ("c" → "assist 2") + - New work branches from "b": + ┌─────┐ + │ user: "e" │ ← new branch + └─────┬─────┘ + ▼ + ┌──────────┐ + │ assist 4 │ + └──────────┘ + +Context sent to LLM: + [compaction summary, user:b, user:e, assist:4] + → The old "c"/"assist 2" branch is replaced by its summary +``` + +--- + +## 8. Pending Writes — Batching Session Persistence + +To avoid writing every message individually during a run: + +``` +Agent loop events + │ + ▼ +handleAgentEvent(event) + │ + ├── message_end → pendingSessionWrites.push({ type: "message", message }) + ├── turn_end → flushPendingSessionWrites() (save_point) + ├── agent_end → flushPendingSessionWrites() + │ + ▼ +flushPendingSessionWrites() + │ + ├── Iterate pendingSessionWrites[] + │ ├── message → session.appendMessage(msg) + │ ├── model_change → session.appendModelChange(provider, id) + │ ├── thinking_level_change → session.appendThinkingLevelChange(level) + │ ├── active_tools_change → session.appendActiveToolsChange(names) + │ ├── custom → session.appendCustomEntry(type, data) + │ ├── custom_message → session.appendCustomMessageEntry(...) + │ ├── label → session.appendLabel(targetId, label) + │ ├── session_info → session.appendSessionName(name) + │ └── leaf → session.getStorage().setLeafId(targetId) + │ + └── Shift all writes → empty pending list +``` + +During the run, messages are accumulated in `pendingSessionWrites` and only flushed to disk at `save_point` (end of each turn) or `agent_end`. + +--- + +## 9. Hooks & Extensibility — Context Control Points + +The harness exposes hooks at every memory management boundary: + +``` +┌────────────────────────────────────────────────────────────────┐ +│ Hooks │ +├────────────────────────┬─────────────────────────────────────┤ +│ Hook │ When │ +├────────────────────────┼─────────────────────────────────────┤ +│ before_agent_start │ Before each prompt, can add │ +│ │ messages or modify system prompt │ +├────────────────────────┼─────────────────────────────────────┤ +│ context │ Before each LLM call, can prune/ │ +│ │ modify AgentMessage[] │ +├────────────────────────┼─────────────────────────────────────┤ +│ before_provider_request│ Before each provider API call, can │ +│ │ modify headers, retries, timeout │ +├────────────────────────┼─────────────────────────────────────┤ +│ before_provider_payload│ Before sending payload to provider,│ +│ │ can modify the request body │ +├────────────────────────┼─────────────────────────────────────┤ +│ after_provider_response│ After receiving response, for │ +│ │ logging/metrics │ +├────────────────────────┼─────────────────────────────────────┤ +│ tool_call │ Before tool execution, can │ +│ │ return { block: true } │ +├────────────────────────┼─────────────────────────────────────┤ +│ tool_result │ After tool execution, can override │ +│ │ content, details, isError, terminate│ +├────────────────────────┼─────────────────────────────────────┤ +│ session_before_compact│ Before compaction, can cancel or │ +│ │ provide custom compaction result │ +├────────────────────────┼─────────────────────────────────────┤ +│ session_before_tree │ Before branch navigation, can │ +│ │ cancel or provide custom summary │ +├────────────────────────┼─────────────────────────────────────┤ +│ prepareNextTurn │ Between turns, can replace context, │ +│ │ model, or thinkingLevel │ +├────────────────────────┼─────────────────────────────────────┤ +│ shouldStopAfterTurn │ After a turn, if true the loop │ +│ │ exits (agent_end, no more LLM calls)│ +└────────────────────────┴─────────────────────────────────────┘ +``` + +--- + +## 10. Complete Lifecycle: Long Session + +``` +Session starts empty + │ + ▼ +Turn 1: "Create a React component" + Context: [compaction summary (empty)] + Messages exchanged: ~2K tokens + └─ Session: [user1, assist1, toolCall, toolRes1, assist2] + │ + ▼ +Turn 2-10: Iterative development + Context: growing with each turn + Total context: ~50K tokens + └─ Session: [user1..assist2, user2..assist20] + │ + ▼ +Turn 15: Context approaching limit (~170K tokens) + shouldCompact() → true + └─ Compaction 1: + - Summarizes turns 1-12 + - Keeps turns 13-15 verbatim + - Summary: "## Goal: React component ## Progress: built X, Y" + │ + ▼ +Turn 20: Context ~180K tokens + shouldCompact() → true + └─ Compaction 2 (iterative update): + - Updates existing summary with new progress + - "## Goal: React component ## Done: built X,Y ## New: added auth" + │ + ▼ +Turn 25: Context ~186K tokens → triggers compaction + └─ Compaction 3: + - Summary now covers ~22 turns of history + - Retained tail: last 20K tokens (~5 turns) + - Context window freed: ~186K → ~25K tokens + │ + ▼ +User navigates to Turn 8: + - Branch summary generated for Turns 9-25 + - Leaf moves back to Turn 8 + - Context: [compaction, branch_summary, turns 1-8] + │ + ▼ +User continues from Turn 8: + - New branch grows from Turn 8 + - Old branch (9-25) replaced by branch_summary + │ + ▼ +Session ends, JSONL file persists on disk + Next session: loads from JSONL, rebuilds context +``` + +--- + +## 11. Token Budget Summary + +``` +Example: Claude Sonnet (200K context window) + + ┌─────────────────────────────────────────────────────────────┐ + │ Context Window: 200,000 tokens │ + ├─────────────────────────────────────────────────────────────┤ + │ Reserved for summary: 16,384 tokens │ + ├─────────────────────────────────────────────────────────────┤ + │ Keep recent: 20,000 tokens │ + ├─────────────────────────────────────────────────────────────┤ + │ Max context before compaction: 183,616 tokens │ + │ (= 200000 - 16384) │ + ├─────────────────────────────────────────────────────────────┤ + │ After compaction: ~25,000 tokens │ + │ (20,000 tail + ~5,000 summary) │ + │ → ~158,616 tokens freed │ + └─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 12. Key Files Reference + +| File | Responsibility | +|------|---------------| +| `agent-loop.ts` | Core loop, tool execution, streaming | +| `agent.ts` | Stateful `Agent` class, event system | +| `agent-harness.ts` | High-level harness, hooks, session management | +| `compaction/compaction.ts` | Token estimation, cut point, LLM summarization | +| `compaction/branch-summarization.ts` | Branch divergence summarization | +| `session/session.ts` | Session tree, entry appending, context building | +| `session/jsonl-storage.ts` | JSONL file read/write | +| `session/jsonl-repo.ts` | Session repo: create/open/list/delete/fork | +| `types.ts` | All type definitions | +| `messages.ts` | `convertToLlm()`, custom message helpers | +| `system-prompt.ts` | System prompt building | +| `skills.ts` | Skill management | diff --git a/src/llmfunction.jl b/src/llmfunction.jl index 8d5cc52..0d42110 100644 --- a/src/llmfunction.jl +++ b/src/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 + Base64, Serde, LibPQ, NATS using GeneralUtils, SQLLLM using ..type, ..util @@ -320,13 +320,27 @@ function search_wine_database!(a::T, thoughtdict::AbstractDict; useSQLLLM::Bool= @show vector_search @show vector_search_str + + config = a.context.agentconfig + host_url, _port = split(config["externalservice"]["sommpanion_db"]["url"], ':') + port = parse(Int, _port) + dbname = "winedb" + user = config["externalservice"]["sommpanion_db"]["user"] + password = config["externalservice"]["sommpanion_db"]["password"] + pg_conn_str = "host=$host_url port=$port dbname=$dbname user=$user password=$password" + #WORKING - GeneralUtils.find_text_vector_similarity(vector_search_str, "wine", "tasting_notes_embedding", - GeneralUtils.execute) - - + df = GeneralUtils.find_text_vector_similarity( + vector_search_str, + "wine", + "tasting_notes_embedding", + GeneralUtils.execute_postgres_sql(pg_conn_str, sql), #BUG input pair (F, arg) + a.context.getTextEmbedding([vector_search_str]) #BUG input pair (F, arg) + ) + @show df + error(888888) items = nothing @@ -1500,13 +1514,6 @@ end - - - - - - - function classify_column(pg_conn_str::String, table_name::String, column_name::String; sample_size::Integer=1000) conn = LibPQ.Connection(pg_conn_str) @@ -1608,44 +1615,28 @@ end -function harvest_entity_catalog(pg_conn_str::String, table::String, column::String) - conn = LibPQ.Connection(pg_conn_str) - return harvest_entity_catalog(conn, table, column) -end -function harvest_entity_catalog_with_pg_type(conn::LibPQ.Connection, table::String, column::String) - try - # 1. Query the actual data - data_query = "SELECT DISTINCT $(column) FROM $(table) WHERE $(column) IS NOT NULL;" - df = DataFrame(LibPQ.execute(conn, data_query)) - values = String.(strip.(string.(df[!, 1]))) - - # 2. Query the database schema for the column's data type - # Note: Postgres stores unquoted table/column names in lowercase - type_query = """ - SELECT data_type - FROM information_schema.columns - WHERE table_name = lower('$(table)') - AND column_name = lower('$(column)'); - """ - type_df = DataFrame(LibPQ.execute(conn, type_query)) - pg_type = isempty(type_df) ? "unknown" : type_df[1, 1] - - return (values = values, type = pg_type) - - catch e - @error "Failed to harvest catalog" exception=e - return (values = String[], type = "unknown") - finally - close(conn) - end -end -# Usage: -# result = harvest_entity_catalog_with_pg_type(conn, "users", "created_at") -# println(result.values) # ["2023-01-01", "2023-02-15"] -# println(result.type) # "timestamp without time zone" + + + + + + + + + + + + + + + + + + + -- 2.52.0 From 597319a1473a6a065a3a943c39f4faf025e0e3e0 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 26 Jul 2026 22:21:10 +0700 Subject: [PATCH 6/7] update --- learn_pi_agent/agent_workflow.md | 473 ----- learn_pi_agent/architecture.md | 2798 --------------------------- learn_pi_agent/memory_management.md | 624 ------ src/llmfunction.jl | 18 +- 4 files changed, 9 insertions(+), 3904 deletions(-) delete mode 100644 learn_pi_agent/agent_workflow.md delete mode 100644 learn_pi_agent/architecture.md delete mode 100644 learn_pi_agent/memory_management.md diff --git a/learn_pi_agent/agent_workflow.md b/learn_pi_agent/agent_workflow.md deleted file mode 100644 index 29dd313..0000000 --- a/learn_pi_agent/agent_workflow.md +++ /dev/null @@ -1,473 +0,0 @@ -# Agent Workflow: Message Handling and Response Generation - -## Overview - -This document explains the complete step-by-step flow of how the agent processes a user message and generates a response, from the moment a user asks "what product do you have in stock" to when the agent responds with an answer. - ---- - -## Architecture Diagram - -``` -┌─────────────────────────────────────────────────────────────────────────────────────┐ -│ USER INPUT │ -│ "what product do you have in stock?" │ -└─────────────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────────────┐ -│ 1. AGENT.PROMPT() ENTRY │ -│ File: packages/agent/src/agent.ts:339 │ -│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ -│ │ • Validate no active run (throws if busy) │ │ -│ │ • normalizePromptInput() converts string to AgentMessage[] │ │ -│ │ • runPromptMessages() launches execution with lifecycle events │ │ -│ └──────────────────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────────────┐ -│ 2. AGENT LIFECYCLE INITIALIZATION │ -│ File: packages/agent/src/agent.ts:398-412 │ -│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ -│ │ Events Emitted: │ │ -│ │ • agent_start │ │ -│ │ • turn_start │ │ -│ │ • message_start / message_end (for each prompt message) │ │ -│ │ │ │ -│ │ Context Snapshot Created: │ │ -│ │ • systemPrompt │ │ -│ │ • messages (copy) │ │ -│ │ • tools (copy) │ │ -│ └──────────────────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────────────┐ -│ 3. AGENT LOOP STARTS │ -│ File: packages/agent/src/agent-loop.ts:95 │ -│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ -│ │ runPromptMessages() → runAgentLoop() │ │ -│ │ • Prompts added to context.messages │ │ -│ │ • Lifecycle events emitted for prompts │ │ -│ │ • Calls runLoop() (main processing loop) │ │ -│ └──────────────────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────────────┐ -│ 4. LLM CALL BOUNDARY - MESSAGE TRANSFORMATION │ -│ File: packages/agent/src/agent-loop.ts:281-372 (streamAssistantResponse) │ -│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ -│ │ Step 4.1: Context Transform (optional) │ │ -│ │ transformContext(messages) → transformed messages │ │ -│ │ (Used for context pruning/injection) │ │ -│ │ │ │ -│ │ Step 4.2: LLM Conversion │ │ -│ │ convertToLlm(messages) → Message[] │ │ -│ │ - Filters non-LLM messages (bashExecution, branchSummary, etc.) │ │ -│ │ - Converts: user → user, assistant → assistant, toolResult → toolResult │ │ -│ │ │ │ -│ │ Step 4.3: Build LLM Context │ │ -│ │ { │ │ -│ │ systemPrompt: context.systemPrompt, │ │ -│ │ messages: llmMessages, │ │ -│ │ tools: context.tools │ │ -│ │ } │ │ -│ │ │ │ -│ │ Step 4.4: Resolve API Key │ │ -│ │ getApiKey(model.provider) → apiKey │ │ -│ │ │ │ -│ │ Step 4.5: Stream Function Call │ │ -│ │ streamFunction(model, llmContext, options) │ │ -│ │ - Default: Models.streamSimple() from @earendil-works/pi-ai │ │ -│ │ - Makes actual LLM API call │ │ -│ └──────────────────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────────────┐ -│ 5. LLM RESPONSE STREAMING │ -│ File: packages/agent/src/agent-loop.ts:317-371 │ -│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ -│ │ Stream yields events: │ │ -│ │ • start → creates partial AssistantMessage │ │ -│ │ • text_start → streaming text begins │ │ -│ │ • text_delta → text chunks arrive │ │ -│ │ • toolcall_start → tool call block begins │ │ -│ │ • toolcall_delta → tool call arguments arrive │ │ -│ │ • toolcall_end → tool call block complete │ │ -│ │ • text_end → text block complete │ │ -│ │ • done → final message complete │ │ -│ │ │ │ -│ │ State Updates: │ │ -│ │ • Partial message pushed to context.messages │ │ -│ │ • message_start event emitted │ │ -│ │ • message_update events emitted as text/tools stream in │ │ -│ │ • Final message committed to context.messages │ │ -│ │ • message_end event emitted │ │ -│ │ │ │ -│ │ Stop Reasons: │ │ -│ │ • stop - normal completion │ │ -│ │ • toolUse - model requested tool calls │ │ -│ │ • length - token limit reached │ │ -│ │ • error - failure │ │ -│ │ • aborted - operation aborted │ │ -│ └──────────────────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────────────┐ -│ 6. TOOL CALL PARSING AND EXECUTION │ -│ File: packages/agent/src/agent-loop.ts:408-554 │ -│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ -│ │ Step 6.1: Extract Tool Calls │ │ -│ │ toolCalls = message.content.filter(c => c.type === "toolCall") │ │ -│ │ │ │ -│ │ Step 6.2: Determine Execution Mode │ │ -│ │ - Check config.toolExecution: "parallel" or "sequential" │ │ -│ │ - Check individual tool executionMode setting │ │ -│ │ - Decides how to execute tool batch │ │ -│ │ │ │ -│ │ Step 6.3: For Each Tool Call │ │ -│ │ ┌──────────────────────────────────────────────────────────────────────┐ │ │ -│ │ │ 1. Tool Lookup │ │ │ -│ │ │ tool = context.tools.find(t => t.name === toolCall.name) │ │ │ -│ │ │ │ │ │ -│ │ │ 2. Argument Preparation │ │ │ -│ │ │ prepared = tool.prepareArguments?(toolCall.arguments) │ │ │ -│ │ │ │ │ │ -│ │ │ 3. Argument Validation │ │ │ -│ │ │ validateToolArguments(tool, preparedToolCall) │ │ │ -│ │ │ │ │ │ -│ │ │ 4. Before Tool Hook │ │ │ -│ │ │ beforeToolCall({ assistantMessage, toolCall, args, context }) │ │ │ -│ │ │ - Can block execution by returning { block: true, reason } │ │ │ -│ │ │ │ │ │ -│ │ │ 5. Execution │ │ │ -│ │ │ execute(toolCallId, params, signal, onUpdate, context) │ │ │ -│ │ │ │ │ │ -│ │ │ - Parallel Mode: Tools execute concurrently │ │ │ -│ │ │ - Sequential Mode: Tools execute one-by-one │ │ │ -│ │ └──────────────────────────────────────────────────────────────────────┘ │ │ -│ └──────────────────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────────────┐ -│ 7. TOOL EXECUTION EXAMPLE - READ TOOL │ -│ File: packages/agent/src/harness/tools/read.ts │ -│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ -│ │ User asks: "what product do you have in stock?" │ │ -│ │ │ │ -│ │ Agent decides to read catalog file "products.json" │ │ -│ │ │ │ -│ │ Input Arguments: │ │ -│ │ { │ │ -│ │ "path": "products.json", │ │ -│ │ "offset": 1, │ │ -│ │ "limit": 100 │ │ -│ │ } │ │ -│ │ │ │ -│ │ Execution Steps: │ │ -│ │ 1. resolveReadToolPath(env, path, signal) → absolutePath │ │ -│ │ 2. env.readBinaryFile(absolutePath, signal) → bytes │ │ -│ │ 3. Detect mimeType (check if image) │ │ -│ │ 4. For text files: │ │ -│ │ - Decode UTF-8 → textContent │ │ -│ │ - Split by lines → allLines │ │ -│ │ - Apply offset/limit slicing │ │ -│ │ - Truncate if exceeds DEFAULT_MAX_BYTES or DEFAULT_MAX_LINES │ │ -│ │ - Add truncation notice to output │ │ -│ │ 5. Return result: │ │ -│ │ { │ │ -│ │ content: [{ type: "text", text: output }], │ │ -│ │ details: { truncation: ... } │ │ -│ │ } │ │ -│ │ │ │ -│ │ Output Example: │ │ -│ │ "Showing lines 1-50 of 150. [Showing 50 lines of 150. Use offset=51 to │ │ -│ │ continue.]" │ │ -│ │ │ │ -│ │ Tool Result: │ │ -│ │ { │ │ -│ │ "role": "toolResult", │ │ -│ │ "toolCallId": "tool_abc123", │ │ -│ │ "toolName": "read", │ │ -│ │ "content": [{ "type": "text", "text": "..." }], │ │ -│ │ "isError": false, │ │ -│ │ "timestamp": 1721721600000 │ │ -│ │ } │ │ -│ └──────────────────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────────────┐ -│ 8. TOOL RESULT HANDLING │ -│ File: packages/agent/src/agent-loop.ts:556-792 │ -│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ -│ │ Step 8.1: After Tool Hook │ │ -│ │ afterToolCall({ assistantMessage, toolCall, args, result, isError, ctx }) │ │ -│ │ - Can override result content, details, usage, terminate hint │ │ -│ │ │ │ -│ │ Step 8.2: Create Tool Result Message │ │ -│ │ { │ │ -│ │ role: "toolResult", │ │ -│ │ toolCallId: toolCall.id, │ │ -│ │ toolName: toolCall.name, │ │ -│ │ content: result.content ?? [], │ │ -│ │ details: result.details, │ │ -│ │ usage: result.usage, │ │ -│ │ isError: false, │ │ -│ │ timestamp: Date.now() │ │ -│ │ } │ │ -│ │ │ │ -│ │ Step 8.3: Emit Events │ │ -│ │ • tool_execution_start │ │ -│ │ • tool_execution_end │ │ -│ │ • message_start (toolResult message) │ │ -│ │ • message_end (toolResult message) │ │ -│ │ │ │ -│ │ Step 8.4: Update Context │ │ -│ │ • Push tool result message to currentContext.messages │ │ -│ │ • Push to newMessages array │ │ -│ │ │ │ -│ │ Step 8.5: Batch Termination Check │ │ -│ │ shouldTerminateToolBatch(finalizedCalls) │ │ -│ │ - Returns true if ALL tools set terminate: true │ │ -│ │ - If true, agent may stop after this batch │ │ -│ └──────────────────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────────────┐ -│ 9. NEXT TURN PREPARATION │ -│ File: packages/agent/src/agent-loop.ts:224-257 │ -│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ -│ │ Step 9.1: Turn End Event │ │ -│ │ turn_end emitted with message and toolResults │ │ -│ │ │ │ -│ │ Step 9.2: prepareNextTurn Hook │ │ -│ │ prepareNextTurn({ message, toolResults, context, newMessages }) │ │ -│ │ - Can return updated context, model, or thinking level │ │ -│ │ - Used for dynamic context management │ │ -│ │ │ │ -│ │ Step 9.3: Queue Polling │ │ -│ │ getSteeringMessages() → inject messages for immediate processing │ │ -│ │ getFollowUpMessages() → check for queued follow-up messages │ │ -│ │ │ │ -│ │ Step 9.4: Loop Decision │ │ -│ │ • If steering messages exist → process them, continue loop │ │ -│ │ • If follow-up messages exist → process them, continue loop │ │ -│ │ • If tool calls remain in message → continue inner loop │ │ -│ │ • If no messages → emit agent_end, exit loop │ │ -│ └──────────────────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────────────┐ -│ 10. AGENT RESPONSE GENERATION │ -│ File: packages/agent/src/agent-loop.ts:58-163 │ -│ ┌──────────────────────────────────────────────────────────────────────────────┐ │ -│ │ The agent loop continues until: │ │ -│ │ • No tool calls remain in assistant messages │ │ -│ │ • No steering/follow-up messages queued │ │ -│ │ • shouldStopAfterTurn() returns true (if configured) │ │ -│ │ │ │ -│ │ Final Response Generation: │ │ -│ │ 1. LLM streams text content blocks │ │ -│ │ 2. Message committed to context │ │ -│ │ 3. turn_end emitted │ │ -│ │ 4. agent_end emitted with all new messages │ │ -│ │ 5. Agent returns to idle state │ │ -│ │ │ │ -│ │ Final Agent Response: │ │ -│ │ "We have 15 products in stock:" │ │ -│ │ • Product A - $29.99 │ │ -│ │ • Product B - $49.99 │ │ -│ │ • Product C - $19.99 │ │ -│ │ (and 2 more products) │ │ -│ └──────────────────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────────────┘ - -``` - ---- - -## Complete Event Flow Diagram - -``` -┌─────────────────────────────────────────────────────────────────────────────────────┐ -│ COMPLETE EVENT SEQUENCE FOR A TURN │ -│ WITH TOOL USE (Product Catalog Query) │ -└─────────────────────────────────────────────────────────────────────────────────────┘ - -agent_start - ↓ -turn_start - ↓ -message_start (user message: "what product do you have in stock?") -message_end (user message) - ↓ -message_start (assistant message - streaming from LLM) -message_update (text delta: "We have") -message_update (toolcall delta: {"name":"read","arguments":{...}}) -message_update (toolcall end) -message_end (assistant message with tool calls) - ↓ -tool_execution_start (tool call: read products.json) -tool_execution_end (tool call: read complete) -message_start (toolResult message) -message_end (toolResult message) - ↓ -turn_end - ↓ -[Inner loop continues: send tool result to LLM] - ↓ -turn_start - ↓ -message_start (assistant message - streaming from LLM) -message_update (text delta: "We have 15 products in stock:") -message_update (text delta: "• Product A - $29.99") -message_update (text delta: "• Product B - $49.99") -message_end (final assistant message) - ↓ -turn_end - ↓ -agent_end -``` - ---- - -## Key Files Summary - -| Component | File | Purpose | -|-----------|------|---------| -| Agent class | `packages/agent/src/agent.ts` | Stateful wrapper, event emission, queue management | -| Agent loop | `packages/agent/src/agent-loop.ts` | Core loop, LLM calls, tool execution | -| Message types | `packages/agent/src/types.ts` | AgentMessage, AgentTool, AgentEvent definitions | -| Harness | `packages/agent/src/harness/agent-harness.ts` | Session integration, hooks, persistence | -| Read tool | `packages/agent/src/harness/tools/read.ts` | File reading implementation | -| Stream function | `packages/agent/src/stream-fn.ts` | Default stream function management | -| Types | `packages/agent/src/types.ts` | All type definitions | - ---- - -## Hook Points for Customization - -The agent supports multiple extension points: - -| Hook | Location | Purpose | -|------|----------|---------| -| `convertToLlm` | `agent.ts:99` | Transform messages before LLM call | -| `transformContext` | `agent.ts:100` | Modify context (pruning, injection) | -| `beforeToolCall` | `agent.ts:105` | Block or modify tool execution | -| `afterToolCall` | `agent.ts:106` | Override tool results | -| `prepareNextTurn` | `agent.ts:107` | Dynamic context/model updates | -| `shouldStopAfterTurn` | `agent-loop.ts` | Graceful termination | -| `getSteeringMessages` | `agent.ts:114` | Inject messages mid-turn | -| `getFollowUpMessages` | `agent.ts:115` | Queue follow-up messages | - ---- - -## Tool Execution Flow - -``` -Tool Call Received from LLM - ↓ -1. Tool Lookup (find by name) - ↓ -2. prepareArguments? (transform if defined) - ↓ -3. validateToolArguments (JSON Schema) - ↓ -4. beforeToolCall hook (can block) - ↓ -5. execute (parallel or sequential) - ↓ -6. onUpdate (stream partial results) - ↓ -7. afterToolCall hook (can override) - ↓ -8. Create toolResult message - ↓ -9. Emit events (start, end) - ↓ -10. Add to context.messages -``` - ---- - -## Example: "What product do you have in stock?" - -### Step-by-Step Execution: - -1. **User sends message** - ``` - "what product do you have in stock?" - ``` - -2. **Agent normalizes input** - ```typescript - [{ - role: "user", - content: [{ type: "text", text: "what product do you have in stock?" }], - timestamp: Date.now() - }] - ``` - -3. **LLM processes and decides to use `read` tool** - ```json - { - "role": "assistant", - "content": [{ - "type": "toolCall", - "name": "read", - "arguments": { - "path": "products.json", - "offset": 1, - "limit": 50 - }, - "id": "tool_abc123" - }] - } - ``` - -4. **Tool execution** - - Read `products.json` (150 lines total) - - Return lines 1-50 with truncation notice - - Add to context as toolResult - -5. **LLM generates final response** - ``` - We have 15 products in stock: - - • Product A - $29.99 - • Product B - $49.99 - • Product C - $19.99 - • Product D - $99.99 - • Product E - $149.99 - (and 10 more products) - - Use offset=51 to continue viewing. - ``` - -6. **Agent emits final response to user** - ---- - -## Summary - -The agent workflow follows a clear pattern: - -1. **Message Input** → Normalize and validate -2. **Context Setup** → Create snapshot with system prompt, messages, tools -3. **LLM Call** → Transform messages, resolve API key, stream response -4. **Tool Detection** → Check for tool calls in assistant message -5. **Tool Execution** → Validate, hook, execute, stream updates -6. **Result Handling** → Create toolResult message, emit events -7. **Next Turn** → Check for steering/follow-up messages, prepare context -8. **Response Generation** → Continue until no more tool calls needed -9. **Completion** → Emit final response to user - -The entire flow is event-driven, allowing for real-time updates and hook-based customization at every step. diff --git a/learn_pi_agent/architecture.md b/learn_pi_agent/architecture.md deleted file mode 100644 index cc49527..0000000 --- a/learn_pi_agent/architecture.md +++ /dev/null @@ -1,2798 +0,0 @@ -# Agent Architecture: Pi Agent Core - A Comprehensive Guide for Julia Implementation - -## Table of Contents -1. [Overview](#overview) -2. [Architecture Layers](#architecture-layers) -3. [Core Components](#core-components) -4. [Message System](#message-system) -5. [Agent Loop](#agent-loop) -6. [Tool Execution](#tool-execution) -7. [Session Management](#session-management) -8. [Memory & Context Management](#memory--context-management) -9. [Event System](#event-system) -10. [Hook System](#hook-system) -11. [Implementation Guide for Julia](#implementation-guide-for-julia) -12. [Data Flow Diagrams](#data-flow-diagrams) -13. [Key Algorithms](#key-algorithms) - ---- - -## Overview - -The Pi Agent Core is a sophisticated stateful agent system with the following characteristics: - -- **Stateful execution**: Maintains conversation history and context across multiple turns -- **Tool execution**: Supports LLM tool calling with parallel/sequential execution modes -- **Event streaming**: Real-time event system for UI updates -- **Session persistence**: JSONL-based persistent storage with tree-structured branching -- **Memory compaction**: Automatic context window management through LLM summarization -- **Flexible extension**: Hook-based customization at every system boundary - -### Key Design Principles - -1. **Separation of concerns**: Core agent logic is separated from storage and provider implementations -2. **Streaming first**: All operations are designed around async streams for responsiveness -3. **Type safety**: Strong TypeScript types for compile-time guarantees -4. **Extensibility**: Hooks at every major boundary allow customization -5. **Persistence**: Session history survives restarts through JSONL files - ---- - -## Architecture Layers - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ User Interface │ -│ (VS Code Extension, CLI, etc.) │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ Agent Harness (agent-harness.ts) │ -│ - Session integration │ -│ - Hook system │ -│ - Skill/prompt template management │ -│ - System prompt building │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ Agent Class (agent.ts) │ -│ - State management (messages, tools, model) │ -│ - Event emission │ -│ - Steering/follow-up queues │ -│ - Active run management │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ Agent Loop (agent-loop.ts) │ -│ - Main execution loop │ -│ - LLM call orchestration │ -│ - Tool execution │ -│ - Context transformation │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ Provider Abstraction │ -│ (@earendil-works/pi-ai - external) │ -│ - LLM API calls │ -│ - Streaming interface │ -│ - Retry policies │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ Session Storage (session/) │ -│ - JSONL file persistence │ -│ - Tree-structured history │ -│ - Compaction and summarization │ -└─────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Core Components - -### 1. Agent Class (`agent.ts`) - -**Purpose**: Stateful wrapper around the low-level agent loop - -**Key responsibilities**: -- Maintain in-memory transcript (`_state.messages`) -- Manage tools, system prompt, model configuration -- Emit lifecycle events to subscribers -- Handle steering/follow-up message queues -- Prevent concurrent runs (single active run at a time) - -**State structure**: -```typescript -interface AgentState { - systemPrompt: string - model: Model - thinkingLevel: ThinkingLevel - tools: AgentTool[] - messages: AgentMessage[] - isStreaming: boolean - streamingMessage?: AgentMessage - pendingToolCalls: Set - errorMessage?: string -} -``` - -**Key methods**: -- `prompt(message)`: Start a new prompt from text, message, or array -- `continue()`: Continue from current context -- `steer(message)`: Queue steering message (interruption during tool execution) -- `followUp(message)`: Queue follow-up message (executes after agent stops) -- `reset()`: Clear all state -- `subscribe(listener)`: Register event listener -- `abort()`: Cancel current operation -- `waitForIdle()`: Wait for completion and event listeners - -### 2. Agent Loop (`agent-loop.ts`) - -**Purpose**: Core execution logic without state management - -**Main entry points**: -- `runAgentLoop(prompts, context, config, emit, signal, streamFn)`: Start new loop with prompts -- `runAgentLoopContinue(context, config, emit, signal, streamFn)`: Continue from existing context - -**Execution phases**: - -#### Phase 1: Outer Loop (Turn Management) -``` -while (true): - 1. Check steering messages (inject if any) - 2. Stream assistant response from LLM - 3. Extract tool calls from response - 4. Execute tool batch - 5. Emit turn_end event - 6. Check prepareNextTurn hook - 7. Check shouldStopAfterTurn hook - 8. Check follow-up messages - 9. If follow-up exists, continue outer loop - 10. If no follow-up, exit -``` - -#### Phase 2: Inner Loop (Tool Call Processing) -``` -while (hasMoreToolCalls || pendingMessages): - 1. Process pending messages - 2. Stream assistant response - 3. Extract tool calls - 4. Execute tool batch (parallel or sequential) - 5. Emit turn_end -``` - -#### Phase 3: LLM Call Boundary -``` -1. transformContext(messages) // Optional pruning/injection -2. convertToLlm(messages) // Filter/custom message conversion -3. Build LLM context -4. Resolve API key -5. Call streamFunction(model, context, options) -6. Stream events from LLM -7. Commit final message to context -``` - -**Tool execution modes**: -- **Parallel** (default): Preflight sequentially, execute allowed tools concurrently -- **Sequential**: Execute tools one-by-one - -### 3. Types System (`types.ts`) - -**Key types**: - -#### AgentMessage -```typescript -type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages] -``` -Extensible union of LLM messages and custom app-specific messages. - -#### AgentTool -```typescript -interface AgentTool extends Tool { - label: string // UI display name - prepareArguments?: (args) => Static // Argument transformation - execute: (toolCallId, params, signal, onUpdate) => Promise - executionMode?: "parallel" | "sequential" // Per-tool override -} -``` - -#### AgentContext -```typescript -interface AgentContext { - systemPrompt: string - messages: AgentMessage[] - tools?: AgentTool[] -} -``` - -#### AgentLoopConfig -Configuration object passed to low-level loop functions, including: -- Model specification -- convertToLlm transformation -- transformContext (optional) -- beforeToolCall hook -- afterToolCall hook -- prepareNextTurn hook -- shouldStopAfterTurn hook -- getSteeringMessages hook -- getFollowUpMessages hook - ---- - -## Message System - -### Message Types - -#### LLM Messages (standard) -- `user`: User input -- `assistant`: LLM response (streaming, contains tool calls) -- `toolResult`: Tool execution result - -#### Custom Messages (app-specific) -- `bashExecution`: Shell command execution result (hidden from LLM by default) -- `custom`: Custom app messages (visible to LLM if projected) -- `branchSummary`: Branch divergence summary -- `compactionSummary`: History compaction result - -### Message Flow - -``` -User Input - ↓ -normalizePromptInput() → AgentMessage[] - ↓ -AgentContext.messages (in-memory) - ↓ -transformContext() (optional, for pruning/injection) - ↓ -convertToLlm() (filters/custom → LLM format) - ↓ -LLM provider (Message[]) - ↓ -AssistantMessage (streamed) - ↓ -AgentContext.messages (committed) -``` - -### Message Commitment - -Messages are committed to `context.messages` at specific points: -1. Assistant partial message: Added when `start` event arrives -2. Assistant final message: Replaces partial on `done`/`error` -3. Tool result message: Added after `tool_execution_end` - ---- - -## Agent Loop - -### Main Algorithm - -```typescript -async function runLoop(initialContext, newMessages, config, signal, emit, streamFn) { - let currentContext = initialContext - let pendingMessages = (await config.getSteeringMessages?.()) || [] - - while (true) { - let hasMoreToolCalls = true - - while (hasMoreToolCalls || pendingMessages.length > 0) { - // Process pending messages (steering/follow-up) - if (pendingMessages.length > 0) { - for (const msg of pendingMessages) { - await emit({ type: "message_start", message: msg }) - await emit({ type: "message_end", message: msg }) - currentContext.messages.push(msg) - newMessages.push(msg) - } - pendingMessages = [] - } - - // Stream assistant response - const message = await streamAssistantResponse( - currentContext, config, signal, emit, streamFn - ) - newMessages.push(message) - - // Check for errors - if (message.stopReason === "error" || message.stopReason === "aborted") { - await emit({ type: "turn_end", message, toolResults: [] }) - await emit({ type: "agent_end", messages: newMessages }) - return - } - - // Extract and execute tool calls - const toolCalls = message.content.filter(c => c.type === "toolCall") - const toolResults = [] - hasMoreToolCalls = false - - if (toolCalls.length > 0) { - const executedBatch = await executeToolCalls( - currentContext, message, config, signal, emit - ) - toolResults.push(...executedBatch.messages) - hasMoreToolCalls = !executedBatch.terminate - - for (const result of toolResults) { - currentContext.messages.push(result) - newMessages.push(result) - } - } - - await emit({ type: "turn_end", message, toolResults }) - - // Check prepareNextTurn hook - const nextTurnSnapshot = await config.prepareNextTurn?.({ - message, toolResults, context: currentContext, newMessages - }) - if (nextTurnSnapshot) { - currentContext = nextTurnSnapshot.context ?? currentContext - config = { ...config, model: nextTurnSnapshot.model ?? config.model } - } - - // Check shouldStopAfterTurn hook - if (await config.shouldStopAfterTurn?.({ ... })) { - await emit({ type: "agent_end", messages: newMessages }) - return - } - - // Get steering messages for next iteration - pendingMessages = (await config.getSteeringMessages?.()) || [] - } - - // Check follow-up messages - const followUpMessages = (await config.getFollowUpMessages?.()) || [] - if (followUpMessages.length > 0) { - pendingMessages = followUpMessages - continue - } - - // No more messages, exit - break - } - - await emit({ type: "agent_end", messages: newMessages }) -} -``` - -### Assistant Response Streaming - -```typescript -async function streamAssistantResponse(context, config, signal, emit, streamFn) { - // 1. Transform context (optional) - let messages = context.messages - if (config.transformContext) { - messages = await config.transformContext(messages, signal) - } - - // 2. Convert to LLM format - const llmMessages = await config.convertToLlm(messages) - - // 3. Build LLM context - const llmContext = { - systemPrompt: context.systemPrompt, - messages: llmMessages, - tools: context.tools, - } - - // 4. Resolve API key - const resolvedApiKey = await config.getApiKey?.(config.model.provider) || config.apiKey - - // 5. Call stream function - const response = await streamFn(config.model, llmContext, { - ...config, apiKey: resolvedApiKey, signal - }) - - // 6. Stream events - let partialMessage: AssistantMessage | null = null - let addedPartial = false - - for await (const event of response) { - switch (event.type) { - case "start": - partialMessage = event.partial - context.messages.push(partialMessage) - addedPartial = true - await emit({ type: "message_start", message: { ...partialMessage } }) - break - - case "text_start" | "text_delta" | "text_end" | - "thinking_start" | "thinking_delta" | "thinking_end" | - "toolcall_start" | "toolcall_delta" | "toolcall_end": - if (partialMessage) { - partialMessage = event.partial - context.messages[context.messages.length - 1] = partialMessage - await emit({ - type: "message_update", - assistantMessageEvent: event, - message: { ...partialMessage } - }) - } - break - - case "done" | "error": { - const finalMessage = await response.result() - if (addedPartial) { - context.messages[context.messages.length - 1] = finalMessage - } else { - context.messages.push(finalMessage) - await emit({ type: "message_start", message: { ...finalMessage } }) - } - await emit({ type: "message_end", message: finalMessage }) - return finalMessage - } - } - } -} -``` - ---- - -## Tool Execution - -### Tool Call Flow - -``` -Assistant Message (with toolCall content blocks) - ↓ -1. Extract tool calls from message.content -2. Determine execution mode (parallel/sequential) -3. For each tool: - - Look up tool by name - - Prepare arguments (prepareArguments hook) - - Validate arguments (JSON Schema) - - beforeToolCall hook (can block) - - Execute tool (parallel or sequential) - - onUpdate stream (optional) - - afterToolCall hook (can override result) - - Create toolResult message - - Emit events - ↓ -4. Check shouldTerminateToolBatch -5. Return toolResult messages -``` - -### Parallel vs Sequential Execution - -#### Parallel Execution -```typescript -async function executeToolCallsParallel(...) { - const finalizedCalls = [] - - // Preflight all tools sequentially - for (const toolCall of toolCalls) { - const preparation = await prepareToolCall(...) - - if (preparation.kind === "immediate") { - finalizedCalls.push(preparation) - } else { - // Queue async execution - finalizedCalls.push(async () => { - const executed = await executePreparedToolCall(preparation, signal, emit) - const finalized = await finalizeExecutedToolCall(...) - return finalized - }) - } - } - - // Execute allowed tools concurrently - const orderedFinalizedCalls = await Promise.all( - finalizedCalls.map(entry => typeof entry === "function" ? entry() : Promise.resolve(entry)) - ) - - // Emit toolResult messages in assistant source order - const messages = [] - for (const finalized of orderedFinalizedCalls) { - const toolResultMessage = createToolResultMessage(finalized) - await emitToolResultMessage(toolResultMessage, emit) - messages.push(toolResultMessage) - } - - return { messages, terminate: shouldTerminateToolBatch(orderedFinalizedCalls) } -} -``` - -#### Sequential Execution -```typescript -async function executeToolCallsSequential(...) { - const finalizedCalls = [] - const messages = [] - - for (const toolCall of toolCalls) { - const preparation = await prepareToolCall(...) - let finalized - - if (preparation.kind === "immediate") { - finalized = preparation - } else { - const executed = await executePreparedToolCall(preparation, signal, emit) - finalized = await finalizeExecutedToolCall(...) - } - - await emitToolExecutionEnd(finalized, emit) - const toolResultMessage = createToolResultMessage(finalized) - await emitToolResultMessage(toolResultMessage, emit) - finalizedCalls.push(finalized) - messages.push(toolResultMessage) - } - - return { messages, terminate: shouldTerminateToolBatch(finalizedCalls) } -} -``` - -### Tool Execution Stages - -1. **Preparation** (`prepareToolCall`) - - Look up tool by name - - Call `prepareArguments` if defined - - Validate with `validateToolArguments` - - Call `beforeToolCall` hook (can block with `{ block: true }`) - - Return `PreparedToolCall` or `ImmediateToolCallOutcome` - -2. **Execution** (`executePreparedToolCall`) - - Call `tool.execute(toolCallId, args, signal, onUpdate)` - - Stream partial results via `onUpdate` - - Handle errors, return `ExecutedToolCallOutcome` - -3. **Finalization** (`finalizeExecutedToolCall`) - - Call `afterToolCall` hook (can override result) - - Return `FinalizedToolCallOutcome` - -4. **Message Creation** (`createToolResultMessage`) - - Create `ToolResultMessage` - - Set `toolCallId`, `toolName`, `content`, `details`, `usage`, `isError`, `timestamp` - ---- - -## Session Management - -### JSONL Storage Format - -```jsonl -{"type":"session","version":3,"id":"abc123","timestamp":"2024-01-15T10:30:00.000Z", - "cwd":"/home/user/project","parentSession":"...","metadata":{}} -{"type":"message","id":"e001","parentId":null,"timestamp":"...","message":{...}} -{"type":"message","id":"e002","parentId":"e001","timestamp":"...","message":{...}} -{"type":"compaction","id":"e003","parentId":"e002","timestamp":"...", - "summary":"## Goal: ...\n...","firstKeptEntryId":"e001", - "tokensBefore":185000} -{"type":"leaf","id":"e004","parentId":"e003","timestamp":"...", - "targetId":"e002"} -``` - -### Entry Types - -| Type | LLM Context? | Purpose | -|------|-------------|---------| -| `message` | Yes | User, assistant, toolResult | -| `compaction` | Yes (as summary message) | Replaces compacted history | -| `branch_summary` | Yes (as summary message) | Summary of diverged branch | -| `leaf` | No | Points to current tree leaf | -| `thinking_level_change` | No | Tracking thinking level changes | -| `model_change` | No | Tracking model changes | -| `active_tools_change` | No | Tracking tool enable/disable | -| `custom` | No (unless projected) | App-defined data | -| `custom_message` | Yes | App-defined messages | -| `label` | No | Human-readable labels | -| `session_info` | No | Session name history | - -### Session Tree (Branching) - -Sessions form a **tree**, not a linear log: - -``` - ┌───[e01]───┐ - │ user: "a" │ - └─────┬──────┘ - ▼ - ┌──────────┐ - │ assist 1 │ - └─────┬────┘ - ▼ - ┌──────────┐ - │ toolCall │ - └─────┬────┘ - ▼ - ┌──────────┐ - │ toolRes 1│ - └─────┬────┘ - ▼ - ┌──────────┐ - │ user: "b"│ ← user goes back here - └─────┬────┘ - │ - ┌─────┴─────┐ - │ │ - ┌──────────┐ ┌──────────┐ - │ user: "c" │ │ user: "d" │ ← branch point - └────┬─────┘ └────┬─────┘ - │ │ - ┌────▼─────┐ ┌────▼─────┐ - │ assist 2 │ │ assist 3 │ ← current leaf (d) - └──────────┘ └──────────┘ -``` - -**Branch navigation**: -1. User navigates to a different point in history -2. Leaf moves to the selected entry -3. Branch summary generated for diverged work -4. New work branches from the selected point - -### Session API - -```typescript -class Session { - async getBranch(fromId?: string): Promise - async buildContext(options?: SessionContextBuildOptions): Promise - async getLeafId(): Promise - async setLeafId(id: string): Promise - async appendMessage(message: AgentMessage): Promise - async appendCompaction(summary: string, firstKeptEntryId: string, tokensBefore: number): Promise - async appendBranchSummary(summary: string, fromId: string): Promise - async appendLeaf(targetId: string): Promise -} -``` - ---- - -## Memory & Context Management - -### Token Estimation - -```typescript -function estimateTokens(message: AgentMessage): number { - switch (message.role) { - case "user": - return message.content.length / 4 // 4 chars ≈ 1 token - - case "assistant": - return message.content.reduce((sum, block) => { - if (block.type === "text") return sum + block.text.length - if (block.type === "thinking") return sum + block.thinking.length - if (block.type === "toolCall") return sum + block.name.length + JSON.stringify(block.arguments).length - return sum - }, 0) - - case "toolResult" | "custom": - return message.content.length / 4 - - case "bashExecution": - return (message.command.length + message.output.length) / 4 - - case "compactionSummary" | "branchSummary": - return message.summary.length / 4 - } -} -``` - -### Compaction Strategy - -**Trigger condition**: -```typescript -function shouldCompact(contextTokens, contextWindow, settings) { - return contextTokens > contextWindow - settings.reserveTokens -} - -// Defaults -const DEFAULT_COMPACTION_SETTINGS = { - enabled: true, - reserveTokens: 16384, // ~16K for summary prompt + output - keepRecentTokens: 20000 // ~20K tokens of recent history -} -``` - -**Cut point finding**: -```typescript -function findCutPoint(entries, startIndex, endIndex, keepRecentTokens) { - let accumulated = 0 - - // Walk backward from endIndex - for (let i = endIndex - 1; i >= startIndex; i--) { - const entry = entries[i] - - // Skip invalid cut points (toolResult stays with its call) - if (entry.type === "message" && entry.message.role === "toolResult") { - continue - } - - const tokens = estimateTokens(entry) - accumulated += tokens - - if (accumulated >= keepRecentTokens) { - return i + 1 // Snap to nearest valid cut point - } - } - - return startIndex -} -``` - -**Compaction preparation**: -```typescript -function prepareCompaction(branchEntries, settings) { - // Find previous compaction - let previousCompaction = null - for (const entry of branchEntries) { - if (entry.type === "compaction") { - previousCompaction = entry - } - } - - // Estimate tokens - const contextTokens = estimateContextTokens(branchEntries) - - // Find cut point - const firstKeptEntryId = findCutPoint(branchEntries, 0, branchEntries.length, settings.keepRecentTokens) - - // Split into groups - const messagesToSummarize = branchEntries.slice(0, firstKeptEntryId) - const retainedTail = branchEntries.slice(firstKeptEntryId) - - // Extract file operations - const fileOps = extractFileOperations(messagesToSummarize, branchEntries, prevIndex) - - return { - previousCompaction, - contextTokens, - firstKeptEntryId, - messagesToSummarize, - retainedTail, - turnPrefixMessages: extractTurnPrefix(messagesToSummarize), - fileOps - } -} -``` - -**Summary generation**: -```typescript -async function generateSummary(messages, previousSummary) { - if (previousSummary) { - // UPDATE_SUMMARIZATION_PROMPT (iterative update) - const prompt = ` - - ${previousSummary} - - - - ${serializeConversation(messages)} - - - Update the previous summary with new progress: - - Add completed tasks - - Update progress - - Add new goals - - Keep existing information - ` - - return await models.completeSimple(model, { systemPrompt, messages: [{ role: "user", content: prompt }] }) - } else { - // FRESH_SUMMARIZATION_PROMPT - const prompt = ` - - ${serializeConversation(messages)} - - - Generate a summary: - ## Goal - ## Constraints & Preferences - ## Progress - ### Done - ### In Progress - ### Blocked - ## Key Decisions - ## Next Steps - ## Critical Context - ## Files read: [...] - ## Files modified: [...] - ` - - return await models.completeSimple(model, { systemPrompt, messages: [{ role: "user", content: prompt }] }) - } -} -``` - ---- - -## Event System - -### Event Types - -#### Agent Lifecycle -- `agent_start`: Agent begins processing -- `agent_end`: Final event for the run - -#### Turn Lifecycle -- `turn_start`: New turn begins -- `turn_end`: Turn completes with assistant message and tool results - -#### Message Lifecycle -- `message_start`: Any message begins -- `message_update`: **Assistant only**. Includes `assistantMessageEvent` with delta -- `message_end`: Message completes - -#### Tool Execution Lifecycle -- `tool_execution_start`: Tool begins -- `tool_execution_update`: Tool streams progress -- `tool_execution_end`: Tool completes - -### Event Flow with Tools - -``` -agent_start - ↓ -turn_start - ↓ -message_start { user message } -message_end { user message } - ↓ -message_start { assistant message - streaming } -message_update { text_delta: "We have" } -message_update { toolcall_delta: {"name":"read","arguments":{...}} } -message_update { toolcall_end } -message_end { assistant message with tool calls } - ↓ -tool_execution_start { tool call: read products.json } -tool_execution_end { tool call: complete } -message_start { toolResult message } -message_end { toolResult message } - ↓ -turn_end { message, toolResults: [toolResult] } - ↓ -[Inner loop continues: send tool result to LLM] - ↓ -turn_start - ↓ -message_start { assistant message } -message_update { text_delta: "We have 15 products" } -message_end { final assistant message } - ↓ -turn_end - ↓ -agent_end -``` - -### Subscription Model - -```typescript -const unsubscribe = agent.subscribe(async (event, signal) => { - switch (event.type) { - case "message_update": - if (event.assistantMessageEvent.type === "text_delta") { - // Stream text to UI - process.stdout.write(event.assistantMessageEvent.delta) - } - break - - case "agent_end": - // Cleanup, save state, etc. - await flushSessionState(signal) - } -}) -``` - -**Subscription semantics**: -- Listeners are awaited in subscription order -- `agent_end` listeners are included in run settlement -- Agent becomes idle only after all awaited listeners finish -- All listeners receive the active abort signal - ---- - -## Hook System - -### Hook Points - -| Hook | Location | Purpose | -|------|----------|---------| -| `convertToLlm` | agent.ts:99 | Transform messages before LLM call | -| `transformContext` | agent.ts:100 | Modify context (pruning, injection) | -| `beforeToolCall` | agent.ts:105 | Block or modify tool execution | -| `afterToolCall` | agent.ts:106 | Override tool results | -| `prepareNextTurn` | agent.ts:107 | Dynamic context/model updates | -| `shouldStopAfterTurn` | agent-loop.ts | Graceful termination | -| `getSteeringMessages` | agent.ts:114 | Inject messages mid-turn | -| `getFollowUpMessages` | agent.ts:115 | Queue follow-up messages | - -### Hook Context Types - -#### BeforeToolCallContext -```typescript -interface BeforeToolCallContext { - assistantMessage: AssistantMessage - toolCall: AgentToolCall - args: unknown // Validated arguments - context: AgentContext -} -``` - -#### AfterToolCallContext -```typescript -interface AfterToolCallContext { - assistantMessage: AssistantMessage - toolCall: AgentToolCall - args: unknown - result: AgentToolResult - isError: boolean - context: AgentContext -} -``` - -#### PrepareNextTurnContext -```typescript -interface PrepareNextTurnContext extends ShouldStopAfterTurnContext {} - -interface ShouldStopAfterTurnContext { - message: AssistantMessage - toolResults: ToolResultMessage[] - context: AgentContext - newMessages: AgentMessage[] -} -``` - -### Hook Return Types - -#### BeforeToolCallResult -```typescript -interface BeforeToolCallResult { - block?: boolean // Prevent execution - reason?: string // Error message if blocked -} -``` - -#### AfterToolCallResult -```typescript -interface AfterToolCallResult { - content?: (TextContent | ImageContent)[] - details?: unknown - isError?: boolean - usage?: Usage - terminate?: boolean // Hint to stop after batch -} -``` - -#### AgentLoopTurnUpdate -```typescript -interface AgentLoopTurnUpdate { - context?: AgentContext - model?: Model - thinkingLevel?: ThinkingLevel -} -``` - ---- - -## Implementation Guide for Julia - -### Architecture Overview - -``` -Julia Agent Implementation -├── agent.jl # Core Agent class -├── agent_loop.jl # Low-level execution loop -├── messages.jl # Message types and conversion -├── tools.jl # Tool execution -├── session.jl # Session persistence -├── compaction.jl # Memory management -├── events.jl # Event system -├── hooks.jl # Hook system -├── types.jl # Type definitions -└── stream.jl # Stream utilities -``` - -### Step 1: Type Definitions (`types.jl`) - -```julia -# thinking_level.jl -@enum ThinkingLevel begin - THINKING_OFF - THINKING_MINIMAL - THINKING_LOW - THINKING_MEDIUM - THINKING_HIGH - THINKING_XHIGH - THINKING_MAX -end - -# tool.jl -mutable struct Tool{TParameters, TDetails} - name::String - label::String - description::String - parameters::TParameters - execute::Function - prepare_arguments::Union{Function, Nothing} - execution_mode::Symbol # :parallel or :sequential -end - -# message.jl -abstract type Message end - -struct UserMessage <: Message - content::Vector{Union{TextContent, ImageContent}} - timestamp::Int64 -end - -struct AssistantMessage <: Message - content::Vector{Union{TextContent, ToolCall, Thinking}} - api::String - provider::String - model::String - usage::Usage - stop_reason::String - error_message::Union{String, Nothing} - timestamp::Int64 -end - -struct ToolResultMessage <: Message - tool_call_id::String - tool_name::String - content::Vector{Union{TextContent, ImageContent}} - details::Any - usage::Union{Usage, Nothing} - is_error::Bool - timestamp::Int64 -end - -struct CustomMessage <: Message - custom_type::String - content::Union{String, Vector{Union{TextContent, ImageContent}}} - display::Bool - details::Any - timestamp::Int64 -end - -struct AgentMessage - message::Union{UserMessage, AssistantMessage, ToolResultMessage, CustomMessage} -end - -# context.jl -struct AgentContext - system_prompt::String - messages::Vector{AgentMessage} - tools::Union{Vector{Tool}, Nothing} -end -``` - -### Step 2: Message System (`messages.jl`) - -```julia -# messages.jl -const COMPACTION_SUMMARY_PREFIX = """ -The conversation history before this point was compacted into the following summary: - - -""" - -const COMPACTION_SUMMARY_SUFFIX = """ - -""" - -const BRANCH_SUMMARY_PREFIX = """ -The following is a summary of a branch that this conversation came back from: - - -""" - -const BRANCH_SUMMARY_SUFFIX = """ - -""" - -function convert_to_llm(messages::Vector{AgentMessage}) - llm_messages = Vector{Any}() - - for msg in messages - if msg.message isa UserMessage - push!(llm_messages, msg.message) - elseif msg.message isa AssistantMessage - push!(llm_messages, msg.message) - elseif msg.message isa ToolResultMessage - push!(llm_messages, msg.message) - elseif msg.message isa CustomMessage - # Convert custom to user message - content = if msg.message.content isa String - [TextContent(msg.message.content)] - else - msg.message.content - end - push!(llm_messages, UserMessage(content, msg.message.timestamp)) - end - end - - return llm_messages -end - -function create_branch_summary_message(summary::String, from_id::String, timestamp::String) - BranchSummaryMessage(summary, from_id, parse(Int64, timestamp)) -end - -function create_compaction_summary_message(summary::String, tokens_before::Int, timestamp::String) - CompactionSummaryMessage(summary, tokens_before, parse(Int64, timestamp)) -end -``` - -### Step 3: Tool Execution (`tools.jl`) - -```julia -# tools.jl -struct ToolExecutionResult{TDetails} - content::Vector{Union{TextContent, ImageContent}} - details::TDetails - usage::Union{Usage, Nothing} - added_tool_names::Vector{String} - terminate::Bool -end - -struct PreparedToolCall - tool_call::ToolCall - tool::Tool - args::Any -end - -struct ImmediateToolOutcome - result::ToolExecutionResult - is_error::Bool -end - -struct ExecutedToolOutcome - result::ToolExecutionResult - is_error::Bool -end - -struct FinalizedToolOutcome - tool_call::ToolCall - result::ToolExecutionResult - is_error::Bool -end - -function prepare_tool_call( - current_context::AgentContext, - assistant_message::AssistantMessage, - tool_call::ToolCall, - config::AgentLoopConfig, - signal::Union{AbortSignal, Nothing} -) - tool = find_tool(current_context.tools, tool_call.name) - - if tool === nothing - return ImmediateToolOutcome( - ToolExecutionResult([TextContent("Tool $(tool_call.name) not found")], Dict(), nothing, [], false), - true - ) - end - - try - prepared_tool_call = prepare_tool_call_arguments(tool, tool_call) - validated_args = validate_tool_arguments(tool, prepared_tool_call) - - if config.before_tool_call !== nothing - before_result = config.before_tool_call( - BeforeToolCallContext(assistant_message, tool_call, validated_args, current_context), - signal - ) - - if before_result.block - return ImmediateToolOutcome( - ToolExecutionResult([TextContent(before_result.reason)], Dict(), nothing, [], false), - true - ) - end - end - - return PreparedToolCall(tool_call, tool, validated_args) - catch error - return ImmediateToolOutcome( - ToolExecutionResult([TextContent(error.message)], Dict(), nothing, [], false), - true - ) - end -end - -function execute_prepared_tool_call( - prepared::PreparedToolCall, - signal::Union{AbortSignal, Nothing}, - emit::Function -) - update_events = Vector{Future}() - accepting_updates = true - - try - result = prepared.tool.execute( - prepared.tool_call.id, - prepared.args, - signal, - partial_result -> begin - if !accepting_updates - return - end - push!(update_events, Threads.@spawn begin - emit({ - type: "tool_execution_update", - tool_call_id: prepared.tool_call.id, - tool_name: prepared.tool_call.name, - args: prepared.tool_call.arguments, - partial_result: partial_result - }) - end) - end - ) - - accepting_updates = false - wait(update_events) - return ExecutedToolOutcome(result, false) - catch error - accepting_updates = false - wait(update_events) - return ExecutedToolOutcome( - ToolExecutionResult([TextContent(error.message)], Dict(), nothing, [], false), - true - ) - finally - accepting_updates = false - end -end - -function execute_tool_calls_parallel(...) - finalized_calls = Vector{Any}() - - # Preflight all tools sequentially - for tool_call in tool_calls - preparation = prepare_tool_call(...) - - if preparation isa ImmediateToolOutcome - push!(finalized_calls, preparation) - else - push!(finalized_calls, Threads.@async begin - executed = execute_prepared_tool_call(preparation, signal, emit) - finalize_executed_tool_call(...) - end) - end - end - - # Execute allowed tools concurrently - ordered_finalized = wait(finalized_calls) - - # Emit toolResult messages - messages = Vector{ToolResultMessage}() - for finalized in ordered_finalized - tool_result_message = create_tool_result_message(finalized) - emit({ type: "message_start", message: tool_result_message }) - emit({ type: "message_end", message: tool_result_message }) - push!(messages, tool_result_message) - end - - return { messages: messages, terminate: should_terminate_tool_batch(ordered_finalized) } -end -``` - -### Step 4: Agent Loop (`agent_loop.jl`) - -```julia -# agent_loop.jl -function run_agent_loop( - prompts::Vector{AgentMessage}, - context::AgentContext, - config::AgentLoopConfig, - emit::Function, - signal::Union{AbortSignal, Nothing}, - stream_fn::Function -) - new_messages = copy(prompts) - current_context = AgentContext( - context.system_prompt, - vcat(context.messages, prompts), - context.tools - ) - - emit({ type: "agent_start" }) - emit({ type: "turn_start" }) - - for prompt in prompts - emit({ type: "message_start", message: prompt }) - emit({ type: "message_end", message: prompt }) - end - - run_loop(current_context, new_messages, config, signal, emit, stream_fn) - - return new_messages -end - -function run_loop( - initial_context::AgentContext, - new_messages::Vector{AgentMessage}, - initial_config::AgentLoopConfig, - signal::Union{AbortSignal, Nothing}, - emit::Function, - stream_fn::Function -) - current_context = initial_context - config = initial_config - first_turn = true - pending_messages = config.get_steering_messages !== nothing ? config.get_steering_messages() : [] - - while true - has_more_tool_calls = true - - while has_more_tool_calls || !isempty(pending_messages) - if !first_turn - emit({ type: "turn_start" }) - else - first_turn = false - end - - # Process pending messages - if !isempty(pending_messages) - for msg in pending_messages - emit({ type: "message_start", message: msg }) - emit({ type: "message_end", message: msg }) - push!(current_context.messages, msg) - push!(new_messages, msg) - end - pending_messages = [] - end - - # Stream assistant response - message = stream_assistant_response(current_context, config, signal, emit, stream_fn) - push!(new_messages, message) - - # Check for errors - if message.stop_reason == "error" || message.stop_reason == "aborted" - emit({ type: "turn_end", message: message, tool_results: [] }) - emit({ type: "agent_end", messages: new_messages }) - return - end - - # Extract tool calls - tool_calls = filter(c -> c.type == "toolCall", message.content) - tool_results = [] - has_more_tool_calls = false - - if !isempty(tool_calls) - executed_batch = execute_tool_calls(current_context, message, config, signal, emit) - append!(tool_results, executed_batch.messages) - has_more_tool_calls = !executed_batch.terminate - - for result in tool_results - push!(current_context.messages, result) - push!(new_messages, result) - end - end - - emit({ type: "turn_end", message: message, tool_results: tool_results }) - - # Check prepareNextTurn hook - if config.prepare_next_turn !== nothing - next_turn_snapshot = config.prepare_next_turn({ - message: message, - tool_results: tool_results, - context: current_context, - new_messages: new_messages - }) - - if next_turn_snapshot !== nothing - current_context = next_turn_snapshot.context !== nothing ? next_turn_snapshot.context : current_context - config = merge(config, model = next_turn_snapshot.model !== nothing ? next_turn_snapshot.model : config.model) - end - end - - # Check shouldStopAfterTurn hook - if config.should_stop_after_turn !== nothing && config.should_stop_after_turn({ - message: message, - tool_results: tool_results, - context: current_context, - new_messages: new_messages - }) - emit({ type: "agent_end", messages: new_messages }) - return - end - - # Get steering messages - pending_messages = config.get_steering_messages !== nothing ? config.get_steering_messages() : [] - end - - # Check follow-up messages - follow_up_messages = config.get_follow_up_messages !== nothing ? config.get_follow_up_messages() : [] - - if !isempty(follow_up_messages) - pending_messages = follow_up_messages - continue - end - - break - end - - emit({ type: "agent_end", messages: new_messages }) -end - -function stream_assistant_response(context, config, signal, emit, stream_fn) - # 1. Transform context (optional) - messages = context.messages - if config.transform_context !== nothing - messages = config.transform_context(messages, signal) - end - - # 2. Convert to LLM format - llm_messages = config.convert_to_llm(messages) - - # 3. Build LLM context - llm_context = Context( - system_prompt = context.system_prompt, - messages = llm_messages, - tools = context.tools - ) - - # 4. Resolve API key - resolved_api_key = config.get_api_key !== nothing ? config.get_api_key(config.model.provider) : config.api_key - - # 5. Call stream function - response = stream_fn(config.model, llm_context, merge(config, api_key = resolved_api_key, signal = signal)) - - # 6. Stream events - partial_message = nothing - added_partial = false - - for event in response - if event.type == "start" - partial_message = event.partial - push!(context.messages, partial_message) - added_partial = true - emit({ type: "message_start", message: deepcopy(partial_message) }) - elseif event.type in ["text_start", "text_delta", "text_end", "thinking_start", "thinking_delta", "thinking_end", "toolcall_start", "toolcall_delta", "toolcall_end"] - if partial_message !== nothing - partial_message = event.partial - context.messages[end] = partial_message - emit({ - type: "message_update", - assistant_message_event: event, - message: deepcopy(partial_message) - }) - end - elseif event.type == "done" || event.type == "error" - final_message = response.result() - - if added_partial - context.messages[end] = final_message - else - push!(context.messages, final_message) - emit({ type: "message_start", message: deepcopy(final_message) }) - end - - emit({ type: "message_end", message: final_message }) - return final_message - end - end -end -``` - -### Step 5: Agent Class (`agent.jl`) - -```julia -# agent.jl -mutable struct ActiveRun - promise::Promise - resolve::Function - abort_controller::AbortController -end - -mutable struct Agent - _state::MutableAgentState - listeners::Set{Function} - steering_queue::PendingMessageQueue - follow_up_queue::PendingMessageQueue - convert_to_llm::Function - transform_context::Union{Function, Nothing} - stream_function::Function - get_api_key::Union{Function, Nothing} - on_payload::Union{Function, Nothing} - on_response::Union{Function, Nothing} - before_tool_call::Union{Function, Nothing} - after_tool_call::Union{Function, Nothing} - prepare_next_turn::Union{Function, Nothing} - prepare_next_turn_with_context::Union{Function, Nothing} - active_run::Union{ActiveRun, Nothing} - session_id::Union{String, Nothing} - thinking_budgets::Union{ThinkingBudgets, Nothing} - transport::Symbol - max_retry_delay_ms::Union{Int, Nothing} - tool_execution::Symbol -end - -struct MutableAgentState - system_prompt::String - model::Model - thinking_level::ThinkingLevel - tools::Vector{Tool} - messages::Vector{AgentMessage} - is_streaming::Bool - streaming_message::Union{AssistantMessage, Nothing} - pending_tool_calls::Set{String} - error_message::Union{String, Nothing} -end - -function Agent(; kwargs...) - state = MutableAgentState( - kwargs[:initial_state].system_prompt, - kwargs[:initial_state].model, - kwargs[:initial_state].thinking_level, - copy(kwargs[:initial_state].tools), - copy(kwargs[:initial_state].messages), - false, - nothing, - Set{String}(), - nothing - ) - - Agent( - state, - Set{Function}(), - PendingMessageQueue(kwargs[:steering_mode] === nothing ? "one-at-a-time" : kwargs[:steering_mode]), - PendingMessageQueue(kwargs[:follow_up_mode] === nothing ? "one-at-a-time" : kwargs[:follow_up_mode]), - kwargs[:convert_to_llm] !== nothing ? kwargs[:convert_to_llm] : default_convert_to_llm, - kwargs[:transform_context], - kwargs[:stream_fn], - kwargs[:get_api_key], - kwargs[:on_payload], - kwargs[:on_response], - kwargs[:before_tool_call], - kwargs[:after_tool_call], - kwargs[:prepare_next_turn], - kwargs[:prepare_next_turn_with_context], - nothing, - kwargs[:session_id], - kwargs[:thinking_budgets], - kwargs[:transport] === nothing ? :auto : kwargs[:transport], - kwargs[:max_retry_delay_ms], - kwargs[:tool_execution] === nothing ? :parallel : kwargs[:tool_execution] - ) -end - -function subscribe(agent::Agent, listener::Function) - push!(agent.listeners, listener) - return () -> delete!(agent.listeners, listener) -end - -function prompt(agent::Agent, input::String) - if agent.active_run !== nothing - throw(ErrorException("Agent is already processing.")) - end - - messages = normalize_prompt_input(input) - run_prompt_messages(agent, messages) -end - -function prompt(agent::Agent, messages::Vector{AgentMessage}) - if agent.active_run !== nothing - throw(ErrorException("Agent is already processing.")) - end - - run_prompt_messages(agent, messages) -end - -function continue(agent::Agent) - if agent.active_run !== nothing - throw(ErrorException("Agent is already processing.")) - end - - last_message = agent._state.messages[end] - - if last_message.message isa AssistantMessage - queued_steering = drain(agent.steering_queue) - - if !isempty(queued_steering) - run_prompt_messages(agent, queued_steering, skip_initial_steering_poll = true) - return - end - - queued_follow_ups = drain(agent.follow_up_queue) - - if !isempty(queued_follow_ups) - run_prompt_messages(agent, queued_follow_ups) - return - end - - throw(ErrorException("Cannot continue from message role: assistant")) - end - - run_continuation(agent) -end - -function run_prompt_messages(agent::Agent, messages::Vector{AgentMessage}, options = Dict()) - run_with_lifecycle(agent) do signal - run_agent_loop( - messages, - create_context_snapshot(agent), - create_loop_config(agent, options), - event -> process_events(agent, event), - signal, - agent.stream_function - ) - end -end - -function create_context_snapshot(agent::Agent) - AgentContext( - agent._state.system_prompt, - copy(agent._state.messages), - copy(agent._state.tools) - ) -end - -function create_loop_config(agent::Agent, options = Dict()) - skip_initial_steering_poll = get(options, :skip_initial_steering_poll, false) - - AgentLoopConfig( - model = agent._state.model, - reasoning = agent._state.thinking_level == THINKING_OFF ? nothing : agent._state.thinking_level, - session_id = agent.session_id, - on_payload = agent.on_payload, - on_response = agent.on_response, - transport = agent.transport, - thinking_budgets = agent.thinking_budgets, - max_retry_delay_ms = agent.max_retry_delay_ms, - tool_execution = agent.tool_execution, - before_tool_call = agent.before_tool_call, - after_tool_call = agent.after_tool_call, - prepare_next_turn = create_prepare_next_turn(agent), - convert_to_llm = agent.convert_to_llm, - transform_context = agent.transform_context, - get_api_key = agent.get_api_key, - get_steering_messages = () -> begin - if skip_initial_steering_poll - skip_initial_steering_poll = false - return [] - end - return drain(agent.steering_queue) - end, - get_follow_up_messages = () -> drain(agent.follow_up_queue) - ) -end - -function create_prepare_next_turn(agent::Agent) - function prepare(context, signal) - if agent.prepare_next_turn_with_context !== nothing - return agent.prepare_next_turn_with_context(context, signal) - end - - if agent.prepare_next_turn !== nothing - return agent.prepare_next_turn(signal) - end - - return nothing - end - - return prepare -end - -function run_with_lifecycle(agent::Agent, executor::Function) - abort_controller = AbortController() - promise = Promise{Void}() - resolve = () -> nothing - - function set_resolve(value) - resolve = value - if !isready(promise) - put!(promise, nothing) - end - end - - agent.active_run = ActiveRun(promise, set_resolve, abort_controller) - - agent._state.is_streaming = true - agent._state.streaming_message = nothing - agent._state.error_message = nothing - - try - executor(abort_controller.signal) - catch error - handle_run_failure(agent, error, abort_controller.signal.aborted) - finally - finish_run(agent) - end -end - -function handle_run_failure(agent::Agent, error, aborted) - failure_message = AssistantMessage( - [TextContent("")], - agent._state.model.api, - agent._state.model.provider, - agent._state.model.id, - EMPTY_USAGE, - aborted ? "aborted" : "error", - error isa ErrorException ? error.message : string(error), - Dates.datetime2timestamp(Dates.now()) - ) - - process_events(agent, { type: "message_start", message: failure_message }) - process_events(agent, { type: "message_end", message: failure_message }) - process_events(agent, { type: "turn_end", message: failure_message, tool_results: [] }) - process_events(agent, { type: "agent_end", messages: [failure_message] }) -end - -function finish_run(agent::Agent) - agent._state.is_streaming = false - agent._state.streaming_message = nothing - agent._state.pending_tool_calls = Set{String}() - - if agent.active_run !== nothing - agent.active_run.resolve() - agent.active_run = nothing - end -end - -function process_events(agent::Agent, event) - if event.type == "message_start" - agent._state.streaming_message = event.message - elseif event.type == "message_update" - agent._state.streaming_message = event.message - elseif event.type == "message_end" - agent._state.streaming_message = nothing - push!(agent._state.messages, event.message) - elseif event.type == "tool_execution_start" - push!(agent._state.pending_tool_calls, event.tool_call_id) - elseif event.type == "tool_execution_end" - delete!(agent._state.pending_tool_calls, event.tool_call_id) - elseif event.type == "turn_end" - if event.message.message isa AssistantMessage && event.message.message.error_message !== nothing - agent._state.error_message = event.message.message.error_message - end - elseif event.type == "agent_end" - agent._state.streaming_message = nothing - end - - signal = agent.active_run !== nothing ? agent.active_run.abort_controller.signal : nothing - - for listener in agent.listeners - Threads.@spawn listener(event, signal) - end -end - -function normalize_prompt_input(input::String) - content = [TextContent(input)] - return [AgentMessage(UserMessage(content, Dates.datetime2timestamp(Dates.now())))] -end - -function normalize_prompt_input(messages::Vector{AgentMessage}) - return messages -end -``` - -### Step 6: Session Persistence (`session.jl`) - -```julia -# session.jl -struct SessionEntry - type::String - id::String - parent_id::Union{String, Nothing} - timestamp::Int64 - # Dynamic fields based on type -end - -struct MessageEntry <: SessionEntry - message::AgentMessage -end - -struct CompactionEntry <: SessionEntry - summary::String - first_kept_entry_id::Union{String, Nothing} - tokens_before::Int -end - -struct BranchSummaryEntry <: SessionEntry - summary::String - from_id::String -end - -struct LeafEntry <: SessionEntry - target_id::String -end - -struct SessionStorage{TMetadata} - file_path::String - metadata::TMetadata - entries::Dict{String, SessionEntry} - leaf_id::Union{String, Nothing} -end - -function create_session(file_system::FileSystem, cwd::String, id::String, timestamp::DateTime) - session_dir = join_path(file_system.sessions_root, encode_cwd(cwd)) - create_dir(session_dir, recursive = true) - - file_path = join_path(session_dir, "$(replace(timestamp, r"[:\.]" => "-"))_$id.jsonl") - - metadata = SessionMetadata( - path = file_path, - cwd = cwd, - session_id = id, - parent_session_path = nothing, - created_at = timestamp - ) - - storage = SessionStorage(file_path, metadata, Dict{String, SessionEntry}(), nothing) - - # Write header - header = Dict( - "type" => "session", - "version" => 3, - "id" => id, - "timestamp" => string(timestamp), - "cwd" => cwd, - "metadata" => Dict{String, Any}() - ) - - open(file_path, "w") do f - write(f, JSON.json(header)) - write(f, "\n") - end - - return storage -end - -function append_message(storage::SessionStorage, message::AgentMessage) - entry_id = uuid7() - timestamp = Dates.datetime2timestamp(Dates.now()) - - entry = Dict( - "type" => "message", - "id" => entry_id, - "parentId" => storage.leaf_id, - "timestamp" => string(DateTime(timestamp)), - "message" => message_to_dict(message) - ) - - open(storage.file_path, "a") do f - write(f, JSON.json(entry)) - write(f, "\n") - end - - storage.entries[entry_id] = entry - storage.leaf_id = entry_id -end - -function append_compaction(storage::SessionStorage, summary::String, first_kept_entry_id::String, tokens_before::Int) - entry_id = uuid7() - timestamp = Dates.datetime2timestamp(Dates.now()) - - entry = Dict( - "type" => "compaction", - "id" => entry_id, - "parentId" => storage.leaf_id, - "timestamp" => string(DateTime(timestamp)), - "summary" => summary, - "firstKeptEntryId" => first_kept_entry_id, - "tokensBefore" => tokens_before - ) - - open(storage.file_path, "a") do f - write(f, JSON.json(entry)) - write(f, "\n") - end - - storage.entries[entry_id] = entry - storage.leaf_id = entry_id -end - -function get_branch(storage::SessionStorage, from_id::Union{String, Nothing} = nothing) - leaf_id = from_id !== nothing ? from_id : storage.leaf_id - - if leaf_id === nothing - return [] - end - - entries = Vector{SessionEntry}() - current_id = leaf_id - - while current_id !== nothing - entry = get(storage.entries, current_id, nothing) - - if entry === nothing - break - end - - push!(entries, entry) - - if entry.type == "compaction" && entry.first_kept_entry_id !== nothing - current_id = entry.first_kept_entry_id - else - current_id = entry.parent_id - end - end - - return reverse(entries) -end -``` - -### Step 7: Compaction (`compaction.jl`) - -```julia -# compaction.jl -const DEFAULT_COMPACTION_SETTINGS = Dict( - :enabled => true, - :reserve_tokens => 16384, - :keep_recent_tokens => 20000 -) - -function estimate_tokens(message::AgentMessage) - if message.message isa UserMessage - return length(join(message.message.content)) / 4 - elseif message.message isa AssistantMessage - total = 0 - for block in message.message.content - if block.type == "text" - total += length(block.text) - elseif block.type == "thinking" - total += length(block.thinking) - elseif block.type == "toolCall" - total += length(block.name) + length(JSON.json(block.arguments)) - end - end - return total - elseif message.message isa ToolResultMessage - return length(join(message.message.content)) / 4 - elseif message.message isa CustomMessage - if message.message.content isa String - return length(message.message.content) / 4 - else - return length(join(message.message.content)) / 4 - end - else - return 0 - end -end - -function estimate_context_tokens(messages::Vector{AgentMessage}) - total = 0 - for message in messages - total += estimate_tokens(message) - end - return total -end - -function should_compact(context_tokens::Int, context_window::Int, settings::Dict) - return context_tokens > context_window - get(settings, :reserve_tokens, 16384) -end - -function find_cut_point(entries::Vector{SessionEntry}, keep_recent_tokens::Int) - accumulated = 0 - - for i = length(entries):-1:1 - entry = entries[i] - - # Skip invalid cut points - if entry.type == "message" && entry.message.message isa ToolResultMessage - continue - end - - tokens = estimate_context_tokens([entry.message]) - accumulated += tokens - - if accumulated >= keep_recent_tokens - return i + 1 - end - end - - return 1 -end - -function prepare_compaction(branch_entries::Vector{SessionEntry}, settings::Dict) - # Find previous compaction - previous_compaction = nothing - for entry in branch_entries - if entry.type == "compaction" - previous_compaction = entry - end - end - - # Estimate tokens - context_tokens = estimate_context_tokens([e.message for e in branch_entries]) - - # Find cut point - cut_point = find_cut_point(branch_entries, get(settings, :keep_recent_tokens, 20000)) - - # Split into groups - messages_to_summarize = branch_entries[1:cut_point] - retained_tail = branch_entries[cut_point:end] - - # Extract file operations - file_ops = extract_file_operations(messages_to_summarize) - - return Dict( - :previous_compaction => previous_compaction, - :context_tokens => context_tokens, - :cut_point => cut_point, - :messages_to_summarize => messages_to_summarize, - :retained_tail => retained_tail, - :file_ops => file_ops - ) -end - -function generate_summary(messages::Vector{AgentMessage}, previous_summary::Union{String, Nothing} = nothing) - conversation = serialize_conversation(messages) - - if previous_summary !== nothing - # Update prompt - prompt = """ - - $previous_summary - - - - $conversation - - - Update the previous summary with new progress. - """ - else - # Fresh prompt - prompt = """ - - $conversation - - - Generate a summary: - ## Goal - ## Constraints & Preferences - ## Progress - ### Done - ### In Progress - ### Blocked - ## Key Decisions - ## Next Steps - ## Critical Context - ## Files read: [...] - ## Files modified: [...] - """ - end - - # Call LLM - response = models.complete_simple( - model, - Context( - system_prompt = "You are a helpful assistant that summarizes conversations.", - messages = [UserMessage([TextContent(prompt)], Dates.datetime2timestamp(Dates.now()))], - tools = nothing - ) - ) - - return response.choices[1].message.content -end - -function compact(preparation::Dict, model::Model, models::Models) - messages_to_summarize = preparation[:messages_to_summarize] - previous_compaction = preparation[:previous_compaction] - - if previous_compaction !== nothing - previous_summary = previous_compaction.summary - summary = generate_summary(messages_to_summarize, previous_summary) - else - summary = generate_summary(messages_to_summarize, nothing) - end - - # Extract file operations - file_ops = preparation[:file_ops] - summary *= "\n\nFiles read: $(file_ops.read_files)" - summary *= "\nFiles modified: $(file_ops.modified_files)" - - return Dict( - :summary => summary, - :first_kept_entry_id => messages_to_summarize[end].id, - :tokens_before => preparation[:context_tokens], - :retained_tail => preparation[:retained_tail] - ) -end -``` - -### Step 8: Event System (`events.jl`) - -```julia -# events.jl -struct AgentEvent - type::String - # Dynamic fields based on type -end - -struct AgentStart <: AgentEvent - type::String = "agent_start" -end - -struct AgentEnd <: AgentEvent - type::String = "agent_end" - messages::Vector{AgentMessage} -end - -struct TurnStart <: AgentEvent - type::String = "turn_start" -end - -struct TurnEnd <: AgentEvent - type::String = "turn_end" - message::AgentMessage - tool_results::Vector{ToolResultMessage} -end - -struct MessageStart <: AgentEvent - type::String = "message_start" - message::AgentMessage -end - -struct MessageUpdate <: AgentEvent - type::String = "message_update" - message::AgentMessage - assistant_message_event::Any -end - -struct MessageEnd <: AgentEvent - type::String = "message_end" - message::AgentMessage -end - -struct ToolExecutionStart <: AgentEvent - type::String = "tool_execution_start" - tool_call_id::String - tool_name::String - args::Any -end - -struct ToolExecutionUpdate <: AgentEvent - type::String = "tool_execution_update" - tool_call_id::String - tool_name::String - args::Any - partial_result::Any -end - -struct ToolExecutionEnd <: AgentEvent - type::String = "tool_execution_end" - tool_call_id::String - tool_name::String - result::Any - is_error::Bool -end - -function create_tool_result_message(finalized) - ToolResultMessage( - finalized.tool_call.id, - finalized.tool_call.name, - finalized.result.content, - finalized.result.details, - finalized.result.usage, - finalized.is_error, - Dates.datetime2timestamp(Dates.now()) - ) -end -``` - -### Step 9: Hook System (`hooks.jl`) - -```julia -# hooks.jl -struct BeforeToolCallContext - assistant_message::AssistantMessage - tool_call::ToolCall - args::Any - context::AgentContext -end - -struct BeforeToolCallResult - block::Bool - reason::Union{String, Nothing} -end - -struct AfterToolCallContext - assistant_message::AssistantMessage - tool_call::ToolCall - args::Any - result::ToolExecutionResult - is_error::Bool - context::AgentContext -end - -struct AfterToolCallResult - content::Union{Vector{Union{TextContent, ImageContent}}, Nothing} - details::Union{Any, Nothing} - is_error::Union{Bool, Nothing} - usage::Union{Usage, Nothing} - terminate::Union{Bool, Nothing} -end - -struct PrepareNextTurnContext - message::AssistantMessage - tool_results::Vector{ToolResultMessage} - context::AgentContext - new_messages::Vector{AgentMessage} -end - -struct AgentLoopTurnUpdate - context::Union{AgentContext, Nothing} - model::Union{Model, Nothing} - thinking_level::Union{ThinkingLevel, Nothing} -end - -function default_before_tool_call(context::BeforeToolCallContext, signal::Union{AbortSignal, Nothing}) - return nothing -end - -function default_after_tool_call(context::AfterToolCallContext, signal::Union{AbortSignal, Nothing}) - return AfterToolCallResult(nothing, nothing, nothing, nothing, nothing) -end - -function default_prepare_next_turn(context::PrepareNextTurnContext) - return nothing -end -``` - -### Step 10: Stream Utilities (`stream.jl`) - -```julia -# stream.jl -struct StreamEvent - type::String - # Dynamic fields based on type -end - -struct StartEvent <: StreamEvent - type::String = "start" - partial::AssistantMessage -end - -struct TextStartEvent <: StreamEvent - type::String = "text_start" -end - -struct TextDeltaEvent <: StreamEvent - type::String = "text_delta" - delta::String - partial::AssistantMessage -end - -struct TextEndEvent <: StreamEvent - type::String = "text_end" -end - -struct ThinkingStartEvent <: StreamEvent - type::String = "thinking_start" -end - -struct ThinkingDeltaEvent <: StreamEvent - type::String = "thinking_delta" - delta::String - partial::AssistantMessage -end - -struct ThinkingEndEvent <: StreamEvent - type::String = "thinking_end" -end - -struct ToolcallStartEvent <: StreamEvent - type::String = "toolcall_start" -end - -struct ToolcallDeltaEvent <: StreamEvent - type::String = "toolcall_delta" - delta::String - partial::AssistantMessage -end - -struct ToolcallEndEvent <: StreamEvent - type::String = "toolcall_end" -end - -struct DoneEvent <: StreamEvent - type::String = "done" -end - -struct ErrorEvent <: StreamEvent - type::String = "error" - error::String -end - -struct Stream - events::Channel{StreamEvent} -end - -function Stream() - return Stream(Channel{StreamEvent}(32)) -end - -function stream_simple(model::Model, context::Context, options::Dict) - stream = Stream() - - Threads.@spawn begin - # Call LLM provider - response = make_llm_call(model, context, options) - - # Stream events - for chunk in response - if chunk.delta !== nothing - push!(stream.events, TextDeltaEvent(chunk.delta, chunk.message)) - end - - if chunk.tool_calls !== nothing - for tool_call in chunk.tool_calls - push!(stream.events, ToolcallDeltaEvent(JSON.json(tool_call), chunk.message)) - end - end - end - - push!(stream.events, DoneEvent()) - close(stream.events) - end - - return stream -end - -function next_event(stream::Stream) - return take!(stream.events) -end - -function isdone(stream::Stream) - return isclosed(stream.events) -end -``` - -### Usage Example - -```julia -# main.jl -using PiAgent - -# Create models -models = create_models() -models.set_provider(anthropic_provider()) -model = models.get_model("anthropic", "claude-sonnet-4-6") - -# Create agent -agent = Agent( - initial_state = AgentState( - system_prompt = "You are a helpful assistant.", - model = model, - thinking_level = THINKING_OFF, - tools = [], - messages = [] - ), - stream_fn = models.stream_simple, - before_tool_call = default_before_tool_call, - after_tool_call = default_after_tool_call -) - -# Subscribe to events -unsubscribe = agent.subscribe() do event, signal - if event.type == "message_update" && event.assistant_message_event.type == "text_delta" - print(event.assistant_message_event.delta) - end -end - -# Run agent -prompt(agent, "Hello!") - -# Clean up -unsubscribe() -``` - ---- - -## Data Flow Diagrams - -### Complete Prompt Flow - -``` -User: "What product do you have in stock?" - -1. normalize_prompt_input() - ↓ - [{ role: "user", content: [{ type: "text", text: "..." }], timestamp: ... }] - -2. run_prompt_messages() - ↓ - create_context_snapshot() - create_loop_config() - -3. run_agent_loop() - ↓ - emit(agent_start) - emit(turn_start) - emit(message_start, message_end) for user prompt - -4. run_loop() - ↓ - stream_assistant_response() - ↓ - transform_context() (optional) - ↓ - convert_to_llm() - ↓ - Build LLM context - ↓ - stream_function() - ↓ - LLM API call - -5. Stream events from LLM - ↓ - start → text_delta* → done - -6. Commit message to context - ↓ - emit(message_start, message_update*, message_end) - -7. Extract tool calls - ↓ - execute_tool_calls() - ↓ - prepare_tool_call() for each - ↓ - execute_prepared_tool_call() - ↓ - finalize_executed_tool_call() - ↓ - create_tool_result_message() - ↓ - emit(tool_execution_start, tool_execution_end) - emit(message_start, message_end) for tool result - -8. emit(turn_end) - ↓ - prepareNextTurn hook - ↓ - shouldStopAfterTurn hook - -9. Check steering/follow-up queues - ↓ - Continue loop or exit - -10. emit(agent_end) -``` - -### Tool Execution Flow - -``` -Assistant Message (with toolCall blocks) - ↓ -extract tool calls - ↓ -determine execution mode - ↓ -for each tool_call: - ↓ - prepare_tool_call() - ├─ find_tool(tool_call.name) - ├─ prepare_tool_call_arguments() - ├─ validate_tool_arguments() - ├─ before_tool_call hook - │ └─ block? → error result - └─ return PreparedToolCall - ↓ - execute_prepared_tool_call() - ├─ tool.execute() - ├─ stream partial results via onUpdate() - └─ return ExecutedToolCallOutcome - ↓ - finalize_executed_tool_call() - ├─ after_tool_call hook - └─ return FinalizedToolCallOutcome - ↓ - create_tool_result_message() - ↓ - emit(tool_execution_start/end) - emit(message_start/end) - ↓ - add to context.messages -``` - -### Session Persistence Flow - -``` -AgentEvent - ↓ -handle_agent_event() - ↓ -pendingSessionWrites.push() - ↓ -flush_pending_session_writes() - ↓ -for each pending write: - ├─ message → session.append_message() - ├─ compaction → session.append_compaction() - ├─ branch_summary → session.append_branch_summary() - ├─ leaf → session.set_leaf_id() - └─ custom → session.append_custom_entry() - ↓ -JSONL file update -``` - ---- - -## Key Algorithms - -### 1. Context Token Estimation - -```typescript -function estimateContextTokens(messages) { - let total = 0 - for (const message of messages) { - total += estimateTokens(message) - } - return total -} - -function estimateTokens(message) { - switch (message.role) { - case "user": - return message.content.reduce((sum, c) => sum + c.text.length, 0) / 4 - - case "assistant": - return message.content.reduce((sum, c) => { - if (c.type === "text") return sum + c.text.length - if (c.type === "thinking") return sum + c.thinking.length - if (c.type === "toolCall") return sum + c.name.length + JSON.stringify(c.arguments).length - return sum - }, 0) - - case "toolResult": - case "custom": - return message.content.reduce((sum, c) => sum + c.text.length, 0) / 4 - - case "bashExecution": - return (message.command.length + message.output.length) / 4 - - case "compactionSummary": - case "branchSummary": - return message.summary.length / 4 - } -} -``` - -### 2. Turn Start Index Detection - -```typescript -function findTurnStartIndex(entries, startIndex, endIndex) { - // Find first message where role is user or assistant - for (let i = endIndex - 1; i >= startIndex; i--) { - const entry = entries[i] - if (entry.type === "message") { - const role = entry.message.role - if (role === "user" || role === "assistant") { - return i - } - } - } - return endIndex -} -``` - -### 3. File Operations Extraction - -```typescript -function extractFileOpsFromMessage(message, fileOps) { - if (message.role === "toolResult") { - const details = message.details - if (details) { - if (details.readFiles) { - for (const f of details.readFiles) fileOps.read.add(f) - } - if (details.modifiedFiles) { - for (const f of details.modifiedFiles) fileOps.edited.add(f) - } - } - } -} - -function createFileOps() { - return { - read: new Set(), - edited: new Set() - } -} - -function computeFileLists(fileOps, messages, entries, prevCompactionIndex) { - return { - readFiles: [...fileOps.read], - modifiedFiles: [...fileOps.edited] - } -} -``` - -### 4. Branch Context Building - -```typescript -function buildContextEntries(pathEntries, options) { - let entries = defaultContextEntryTransform(pathEntries) - for (const transform of options.entryTransforms ?? []) { - entries = transform(entries) - } - return entries -} - -function defaultContextEntryTransform(pathEntries) { - let compaction = null - for (const entry of pathEntries) { - if (entry.type === "compaction") { - compaction = entry - } - } - - if (!compaction) { - return [...pathEntries] - } - - const entries = [compaction] - const compactionIdx = pathEntries.findIndex(e => e.id === compaction.id) - - if (compaction.retainedTail) { - for (let i = compactionIdx + 1; i < pathEntries.length; i++) { - entries.push(pathEntries[i]) - } - return entries - } - - if (compaction.firstKeptEntryId) { - let foundFirstKept = false - for (let i = 0; i < compactionIdx; i++) { - const entry = pathEntries[i] - if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true - if (foundFirstKept) entries.push(entry) - } - } - - for (let i = compactionIdx + 1; i < pathEntries.length; i++) { - entries.push(pathEntries[i]) - } - - return entries -} - -function sessionEntryToContextMessages(entry, index, entries, options) { - if (entry.type === "message") { - return [entry.message] - } - if (entry.type === "compaction") { - return [ - createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp), - ...(entry.retainedTail ?? []) - ] - } - if (entry.type === "branchSummary" && entry.summary) { - return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)] - } - if (entry.type === "custom") { - return [...(options.entryProjectors?.[entry.customType]?.(entry, index, entries) ?? [])] - } - return [] -} -``` - -### 5. JSONL File Format - -```typescript -// Header -{ - type: "session", - version: 3, - id: sessionId, - timestamp: createdAt.toISOString(), - cwd: cwd, - parentSessionPath: parentSessionPath, - metadata: metadata -} - -// Entry types -{ - type: "message", - id: uuid7(), - parentId: previousLeafId, - timestamp: now.toISOString(), - message: { - role: "user" | "assistant" | "toolResult" | "custom", - // ... message fields - } -} - -{ - type: "compaction", - id: uuid7(), - parentId: previousLeafId, - timestamp: now.toISOString(), - summary: "...", - firstKeptEntryId: entryId, - tokensBefore: tokenCount, - details: { - readFiles: [...], - modifiedFiles: [...] - } -} - -{ - type: "branch_summary", - id: uuid7(), - parentId: previousLeafId, - timestamp: now.toISOString(), - summary: "...", - fromId: branchStartId -} - -{ - type: "leaf", - id: uuid7(), - parentId: previousLeafId, - timestamp: now.toISOString(), - targetId: newLeafId -} -``` - -### 6. Tool Call Argument Preparation - -```typescript -function prepareToolCallArguments(tool, toolCall) { - if (!tool.prepareArguments) { - return toolCall - } - - const preparedArguments = tool.prepareArguments(toolCall.arguments) - if (preparedArguments === toolCall.arguments) { - return toolCall - } - - return { - ...toolCall, - arguments: preparedArguments - } -} -``` - -### 7. Tool Batch Termination Check - -```typescript -function shouldTerminateToolBatch(finalizedCalls) { - return finalizedCalls.length > 0 && - finalizedCalls.every(finalized => - finalized.result.terminate === true - ) -} -``` - -### 8. Message Normalization - -```typescript -function normalizePromptInput(input, images) { - if (Array.isArray(input)) { - return input - } - - if (typeof input !== "string") { - return [input] - } - - const content = [{ type: "text", text: input }] - if (images && images.length > 0) { - content.push(...images) - } - - return [{ role: "user", content, timestamp: Date.now() }] -} -``` - ---- - -## Summary - -The Pi Agent Core architecture is a sophisticated stateful agent system with: - -1. **Stateful execution**: Maintains conversation history across multiple turns -2. **Tool execution**: Supports LLM tool calling with parallel/sequential modes -3. **Event streaming**: Real-time event system for UI updates -4. **Session persistence**: JSONL-based persistent storage with tree-structured branching -5. **Memory compaction**: Automatic context window management through LLM summarization -6. **Flexible extension**: Hook-based customization at every system boundary - -The implementation follows these key principles: - -- **Separation of concerns**: Core agent logic separated from storage and provider implementations -- **Streaming first**: All operations designed around async streams for responsiveness -- **Type safety**: Strong types for compile-time guarantees -- **Extensibility**: Hooks at every major boundary allow customization -- **Persistence**: Session history survives restarts through JSONL files - -The Julia implementation should mirror this architecture, using Julia's type system for strong typing, async/await for streaming, and JSON for persistence. diff --git a/learn_pi_agent/memory_management.md b/learn_pi_agent/memory_management.md deleted file mode 100644 index 6c34b2f..0000000 --- a/learn_pi_agent/memory_management.md +++ /dev/null @@ -1,624 +0,0 @@ -# Memory & Context Management in Pi Agent - -## 1. Architecture Overview - -The agent manages memory at **two layers**: - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Agent Harness │ -│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────────┐ │ -│ │ Session │ │ Compaction │ │ Branch Navigation │ │ -│ │ (JSONL tree)│ │ (summarize) │ │ (reset + summarize)│ │ -│ └──────┬───────┘ └──────────────┘ └─────────────────────┘ │ -└─────────┼───────────────────────────────────────────────────────┘ - │ builds context -┌─────────▼───────────────────────────────────────────────────────┐ -│ Agent Class │ -│ ┌───────────────────────────────────────────────────────────┐ │ -│ │ _state.messages: AgentMessage[] (linear transcript) │ │ -│ │ _state.tools, systemPrompt, model │ │ -│ └───────────────────────────────────────────────────────────┘ │ -│ subscribe() → events → UI updates │ -└─────────────────────────────────────────────────────────────────┘ -``` - -**Key separation:** -- **In-memory** (`Agent`): linear transcript for the current run. Cleared on `reset()`. -- **On-disk** (`Session`): persistent tree of entries in JSONL files. Survives restarts. -- **Compaction**: replaces old on-disk history with an LLM-generated summary, controlling context window usage. - ---- - -## 2. Data Flow: From Prompt to LLM Call - -``` -agent.prompt("Read README.md") - │ - ▼ -┌──────────────────────────┐ -│ normalizePromptInput() │ → { role: "user", content: "..." } -└──────────┬───────────────┘ - │ - ▼ -┌──────────────────────────┐ -│ runWithLifecycle() │ → sets isStreaming=true -│ runAgentLoop() │ -└──────────┬───────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ runLoop() — the main while(true) loop │ -│ │ -│ Inner loop: │ -│ 1. Inject steering/follow-up messages │ -│ 2. streamAssistantResponse() │ -│ ┌─────────────────────────────────────────────┐ │ -│ │ transformContext() │ │ -│ │ AgentMessage[] → AgentMessage[] │ │ -│ │ (prune, inject external context) │ │ -│ └──────────────┬──────────────────────────────┘ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────┐ │ -│ │ convertToLlm() │ │ -│ │ AgentMessage[] → Message[] │ │ -│ │ (filter to user/assistant/toolResult only) │ │ -│ └──────────────┬──────────────────────────────┘ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────┐ │ -│ │ streamFunction() → LLM provider │ │ -│ │ { systemPrompt, messages, tools } │ │ -│ └──────────────┬──────────────────────────────┘ │ -│ ▼ │ -│ Stream events: start → delta* → done │ -│ ▼ │ -│ Return AssistantMessage │ -│ 3. Extract toolCall blocks │ -│ 4. If toolCalls: executeToolCalls() │ -│ → create toolResult messages │ -│ → append to context │ -│ 5. Check shouldStopAfterTurn / prepareNextTurn │ -│ 6. Check steering/follow-up queues │ -│ → Loop if more tool calls or queued messages │ -│ │ -│ Outer loop: │ -│ → Check follow-up queue for messages after agent would │ -│ stop │ -└─────────────────────────────────────────────────────────────┘ -``` - ---- - -## 3. Context Building (On-Disk → In-Memory) - -The `AgentHarness` bridges on-disk session data to the in-memory agent loop. - -``` -session.buildContext() - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ getBranch() — walk from leaf → root via parentId │ -│ │ -│ Tree structure: │ -│ │ -│ ┌──────┐ ┌──────┐ ┌──────────┐ ┌──────────┐ │ -│ │msg 1 │───▶│msg 2 │───▶│ msg 3 │───▶│ msg 4 │ │ -│ │user │ │assist│ │ toolCall │ │ user │ │ -│ └──────┘ └──────┘ └──────────┘ └──────────┘ │ -│ │ -│ Path to root: [msg1, msg2, msg3, msg4] │ -└──────────┬────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ defaultContextEntryTransform() — THE KEY STEP │ -│ │ -│ Finds latest "compaction" entry in path: │ -│ │ -│ ┌──────┐ ┌──────────┐ ┌──────┐ ┌──────┐ │ -│ │msg 1 │ │compaction│ │msg 3 │ │msg 4 │ │ -│ │user │ │summary X │ │msg 2 │ │assist│ │ -│ └──────┘ └──────────┘ └──────┘ └──────┘ │ -│ │ │ │ │ │ -│ ├──────────────┤ │ │ │ -│ │ SKIPPED │ │ │ │ -│ │ (summarized)│ │ │ │ -│ └──────────────┼───────────┘ │ │ -│ ▼ ▼ │ -│ Include compaction entry Include entries after │ -│ + firstKeptEntryId the compaction point │ -│ │ -│ Result: [compaction, msg3, msg4] │ -│ → compaction entry becomes a "compactionSummary" message │ -└──────────┬────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ sessionEntryToContextMessages() │ -│ │ -│ For each entry: │ -│ message → [message] │ -│ compaction → [compactionSummary, ...retainedTail] │ -│ branch_summary → [branchSummaryMessage] │ -│ custom_message → [customMessage] │ -│ other → [] (omitted from LLM context) │ -│ │ -│ Flat result: [compactionSummary, msg3, msg4] │ -└──────────┬────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ deriveSessionContextState() │ -│ │ -│ Extracts from entries: │ -│ thinkingLevel ← latest thinking_level_change or assistant │ -│ model ← latest model_change or assistant │ -│ activeTools ← latest active_tools_change │ -└─────────────────────────────────────────────────────────────┘ -``` - ---- - -## 4. Token Estimation - -Before compaction can decide whether to trigger, it needs to know how many tokens the context uses. - -``` -estimateContextTokens(messages) - │ - ├── Has provider-reported usage on last assistant msg? - │ ├── YES → use actual usage + estimate tail - │ │ (accurate — avoids compounding error) - │ │ - │ └── NO → estimate all messages from scratch - │ - ▼ -estimateTokens(message) [character heuristic: chars / 4] - │ - ├── role === "user" - │ content.length / 4 - │ (images ≈ 4800 chars each) - │ - ├── role === "assistant" - │ sum of all content blocks: - │ text blocks → text.length - │ thinking blocks → thinking.length - │ toolCall blocks → name.length + JSON.stringify(args).length - │ - ├── role === "toolResult" / "custom" - │ content.length / 4 - │ - ├── role === "bashExecution" - │ (command.length + output.length) / 4 - │ - └── role === "compactionSummary" / "branchSummary" - summary.length / 4 -``` - -**Why `chars / 4`?** Rough heuristic: ~4 ASCII characters ≈ 1 token. Conservative estimate to avoid under-counting. - ---- - -## 5. Compaction — The Core Memory Management - -### 5.1 Trigger Condition - -``` -shouldCompact(contextTokens, contextWindow, settings) - → contextTokens > contextWindow - reserveTokens - -Defaults: - reserveTokens: 16384 (~16K tokens for summary prompt + output) - keepRecentTokens: 20000 (~20K tokens of recent history to keep) - -Example (Claude with 200K context window): - Triggers when: contextTokens > 200000 - 16384 = 183616 -``` - -### 5.2 Finding the Cut Point - -``` -findCutPoint(entries, startIndex, endIndex, keepRecentTokens) - │ - │ Walk BACKWARD from endIndex - │ - ├── Accumulate estimated tokens per message - ├── Stop when accumulated ≥ keepRecentTokens - ├── Snap to nearest valid cut point - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ Valid cut points (safe to split): │ -│ - user message │ -│ - assistant message │ -│ - custom message │ -│ - branch_summary │ -│ │ -│ NOT valid (tool results stay with their call): │ -│ - toolResult message (skipped) │ -│ │ -│ Example: │ -│ │ -│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ -│ │ u1 │ │ a1 │ │ tr1│ │ u2 │ │ a2 │ │ tr2│ │ u3 │ │ -│ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ │ -│ ▲ ▲ ▲ │ -│ │ │ │ │ -│ └── kept └── cut └── discarded │ -│ (~20K tokens) point history │ -└─────────────────────────────────────────────────────────────┘ -``` - -### 5.3 The Compaction Process - -``` -prepareCompaction(branchEntries, settings) - │ - ├── Find previous compaction (if any) → previousSummary - ├── Estimate tokens of current context - ├── findCutPoint() → firstKeptEntryId - │ - ├── Split into 3 groups: - │ │ - │ ├── messagesToSummarize: entries BEFORE cut point - │ │ (these become the summary) - │ │ - │ ├── retainedTail: entries AFTER cut point - │ │ (these stay verbatim) - │ │ - │ └── turnPrefixMessages: if cut splits a turn - │ (the beginning of an interrupted turn) - │ - └── Extract file operations from messagesToSummarize: - → readFiles, modifiedFiles - -compact(preparation, model, models) - │ - ├── If isSplitTurn: - │ ├── generateSummary(messagesToSummarize) → history summary - │ ├── generateTurnPrefixSummary(turnPrefixMessages) → turn context - │ └── Combine: history + "---" + turn prefix - │ - ├── Else (normal): - │ ├── Has previousSummary? - │ │ ├── YES → UPDATE_SUMMARIZATION_PROMPT (iterative) - │ │ └── NO → SUMMARIZATION_PROMPT (fresh) - │ └── Call LLM with conversation text + prompt - │ - ├── Append file operations: - │ "Files read: [...]\nFiles modified: [...]" - │ - └── Return: - { - summary: "## Goal...\n## Progress...\n...", - firstKeptEntryId: "entry-uuid", - tokensBefore: 185000, - retainedTail: [msg3, msg4, ...], - details: { readFiles: [...], modifiedFiles: [...] } - } -``` - -### 5.4 Summary Format - -``` -The LLM generates a structured summary: - -## Goal -- [What is the user trying to accomplish?] - -## Constraints & Preferences -- [Any constraints, preferences, or requirements] - -## Progress -### Done -- [x] [Completed tasks] -### In Progress -- [ ] [Current work] -### Blocked -- [Issues preventing progress] - -## Key Decisions -- **[Decision]**: [Brief rationale] - -## Next Steps -1. [Ordered list of what should happen next] - -## Critical Context -- [Any data, examples, or references needed to continue] - -Files read: [src/index.ts, package.json] -Files modified: [src/index.ts] -``` - -### 5.5 Iterative Compaction - -Successive compact calls **update** the existing summary rather than replacing it: - -``` -Compaction 1 (at ~185K tokens): - Summary: "## Goal: Build a login page..." - firstKeptEntryId: "entry-003" - -Compaction 2 (at ~185K tokens again): - previousSummary: "## Goal: Build a login page..." - → UPDATE_SUMMARIZATION_PROMPT - → PRESERVES existing information - → ADDS new progress (move "In Progress" → "Done") - → NEW summary: "## Goal: Build a login page... Add OAuth..." - firstKeptEntryId: "entry-003" (same boundary) -``` - ---- - -## 6. Session Storage — JSONL Format - -``` -Session file: .pi/sessions/--home-user--/2024-01-15T10-30-00_abc123.jsonl - -Line 1 (header): - {"type":"session","version":3,"id":"abc123","timestamp":"2024-01-15T10:30:00.000Z", - "cwd":"/home/user/project","parentSession":"...","metadata":{}} - -Line 2+ (entries, one per line): - {"type":"message","id":"e001","parentId":null,"timestamp":"...","message":{...}} - {"type":"message","id":"e002","parentId":"e001","timestamp":"...","message":{...}} - {"type":"compaction","id":"e003","parentId":"e002","timestamp":"...", - "summary":"## Goal: ...\n...","firstKeptEntryId":"e001", - "tokensBefore":185000} - {"type":"leaf","id":"e004","parentId":"e003","timestamp":"...", - "targetId":"e002"} -``` - -### Entry Types - -| Type | LLM Context? | Purpose | -|------|-------------|---------| -| `message` | Yes | User, assistant, toolResult | -| `compaction` | Yes (as summary message) | Replaces compacted history | -| `branch_summary` | Yes (as summary message) | Summary of diverged branch | -| `leaf` | No | Points to current tree leaf | -| `thinking_level_change` | No | Tracking thinking level changes | -| `model_change` | No | Tracking model changes | -| `active_tools_change` | No | Tracking tool enable/disable | -| `custom` | No (unless projector configured) | App-defined data | -| `custom_message` | Yes | App-defined messages | -| `label` | No | Human-readable labels | -| `session_info` | No | Session name history | - ---- - -## 7. Session Tree (Branching) - -Sessions form a **tree**, not a linear log. This lets users "go back" and try a different approach. - -``` -Session tree: - - ┌───[e01]───┐ - │ user: "a" │ - └─────┬──────┘ - ▼ - ┌──────────┐ - │ assist 1 │ - └─────┬────┘ - ▼ - ┌──────────┐ - │ toolCall │ - └─────┬────┘ - ▼ - ┌──────────┐ - │ toolRes 1│ - └─────┬────┘ - ▼ - ┌──────────┐ - │ user: "b"│ ← user goes back here - └─────┬────┘ - │ - ┌─────┴─────┐ - │ │ - ┌──────────┐ ┌──────────┐ - │ user: "c" │ │ user: "d" │ ← branch point - └────┬─────┘ └────┬─────┘ - │ │ - ┌────▼─────┐ ┌────▼─────┐ - │ assist 2 │ │ assist 3 │ ← current leaf (d) - └──────────┘ └──────────┘ - -When user navigates to "user: b": - - Leaf moves from "d" back to "b" - - Branch summary generated for diverged work ("c" → "assist 2") - - New work branches from "b": - ┌─────┐ - │ user: "e" │ ← new branch - └─────┬─────┘ - ▼ - ┌──────────┐ - │ assist 4 │ - └──────────┘ - -Context sent to LLM: - [compaction summary, user:b, user:e, assist:4] - → The old "c"/"assist 2" branch is replaced by its summary -``` - ---- - -## 8. Pending Writes — Batching Session Persistence - -To avoid writing every message individually during a run: - -``` -Agent loop events - │ - ▼ -handleAgentEvent(event) - │ - ├── message_end → pendingSessionWrites.push({ type: "message", message }) - ├── turn_end → flushPendingSessionWrites() (save_point) - ├── agent_end → flushPendingSessionWrites() - │ - ▼ -flushPendingSessionWrites() - │ - ├── Iterate pendingSessionWrites[] - │ ├── message → session.appendMessage(msg) - │ ├── model_change → session.appendModelChange(provider, id) - │ ├── thinking_level_change → session.appendThinkingLevelChange(level) - │ ├── active_tools_change → session.appendActiveToolsChange(names) - │ ├── custom → session.appendCustomEntry(type, data) - │ ├── custom_message → session.appendCustomMessageEntry(...) - │ ├── label → session.appendLabel(targetId, label) - │ ├── session_info → session.appendSessionName(name) - │ └── leaf → session.getStorage().setLeafId(targetId) - │ - └── Shift all writes → empty pending list -``` - -During the run, messages are accumulated in `pendingSessionWrites` and only flushed to disk at `save_point` (end of each turn) or `agent_end`. - ---- - -## 9. Hooks & Extensibility — Context Control Points - -The harness exposes hooks at every memory management boundary: - -``` -┌────────────────────────────────────────────────────────────────┐ -│ Hooks │ -├────────────────────────┬─────────────────────────────────────┤ -│ Hook │ When │ -├────────────────────────┼─────────────────────────────────────┤ -│ before_agent_start │ Before each prompt, can add │ -│ │ messages or modify system prompt │ -├────────────────────────┼─────────────────────────────────────┤ -│ context │ Before each LLM call, can prune/ │ -│ │ modify AgentMessage[] │ -├────────────────────────┼─────────────────────────────────────┤ -│ before_provider_request│ Before each provider API call, can │ -│ │ modify headers, retries, timeout │ -├────────────────────────┼─────────────────────────────────────┤ -│ before_provider_payload│ Before sending payload to provider,│ -│ │ can modify the request body │ -├────────────────────────┼─────────────────────────────────────┤ -│ after_provider_response│ After receiving response, for │ -│ │ logging/metrics │ -├────────────────────────┼─────────────────────────────────────┤ -│ tool_call │ Before tool execution, can │ -│ │ return { block: true } │ -├────────────────────────┼─────────────────────────────────────┤ -│ tool_result │ After tool execution, can override │ -│ │ content, details, isError, terminate│ -├────────────────────────┼─────────────────────────────────────┤ -│ session_before_compact│ Before compaction, can cancel or │ -│ │ provide custom compaction result │ -├────────────────────────┼─────────────────────────────────────┤ -│ session_before_tree │ Before branch navigation, can │ -│ │ cancel or provide custom summary │ -├────────────────────────┼─────────────────────────────────────┤ -│ prepareNextTurn │ Between turns, can replace context, │ -│ │ model, or thinkingLevel │ -├────────────────────────┼─────────────────────────────────────┤ -│ shouldStopAfterTurn │ After a turn, if true the loop │ -│ │ exits (agent_end, no more LLM calls)│ -└────────────────────────┴─────────────────────────────────────┘ -``` - ---- - -## 10. Complete Lifecycle: Long Session - -``` -Session starts empty - │ - ▼ -Turn 1: "Create a React component" - Context: [compaction summary (empty)] - Messages exchanged: ~2K tokens - └─ Session: [user1, assist1, toolCall, toolRes1, assist2] - │ - ▼ -Turn 2-10: Iterative development - Context: growing with each turn - Total context: ~50K tokens - └─ Session: [user1..assist2, user2..assist20] - │ - ▼ -Turn 15: Context approaching limit (~170K tokens) - shouldCompact() → true - └─ Compaction 1: - - Summarizes turns 1-12 - - Keeps turns 13-15 verbatim - - Summary: "## Goal: React component ## Progress: built X, Y" - │ - ▼ -Turn 20: Context ~180K tokens - shouldCompact() → true - └─ Compaction 2 (iterative update): - - Updates existing summary with new progress - - "## Goal: React component ## Done: built X,Y ## New: added auth" - │ - ▼ -Turn 25: Context ~186K tokens → triggers compaction - └─ Compaction 3: - - Summary now covers ~22 turns of history - - Retained tail: last 20K tokens (~5 turns) - - Context window freed: ~186K → ~25K tokens - │ - ▼ -User navigates to Turn 8: - - Branch summary generated for Turns 9-25 - - Leaf moves back to Turn 8 - - Context: [compaction, branch_summary, turns 1-8] - │ - ▼ -User continues from Turn 8: - - New branch grows from Turn 8 - - Old branch (9-25) replaced by branch_summary - │ - ▼ -Session ends, JSONL file persists on disk - Next session: loads from JSONL, rebuilds context -``` - ---- - -## 11. Token Budget Summary - -``` -Example: Claude Sonnet (200K context window) - - ┌─────────────────────────────────────────────────────────────┐ - │ Context Window: 200,000 tokens │ - ├─────────────────────────────────────────────────────────────┤ - │ Reserved for summary: 16,384 tokens │ - ├─────────────────────────────────────────────────────────────┤ - │ Keep recent: 20,000 tokens │ - ├─────────────────────────────────────────────────────────────┤ - │ Max context before compaction: 183,616 tokens │ - │ (= 200000 - 16384) │ - ├─────────────────────────────────────────────────────────────┤ - │ After compaction: ~25,000 tokens │ - │ (20,000 tail + ~5,000 summary) │ - │ → ~158,616 tokens freed │ - └─────────────────────────────────────────────────────────────┘ -``` - ---- - -## 12. Key Files Reference - -| File | Responsibility | -|------|---------------| -| `agent-loop.ts` | Core loop, tool execution, streaming | -| `agent.ts` | Stateful `Agent` class, event system | -| `agent-harness.ts` | High-level harness, hooks, session management | -| `compaction/compaction.ts` | Token estimation, cut point, LLM summarization | -| `compaction/branch-summarization.ts` | Branch divergence summarization | -| `session/session.ts` | Session tree, entry appending, context building | -| `session/jsonl-storage.ts` | JSONL file read/write | -| `session/jsonl-repo.ts` | Session repo: create/open/list/delete/fork | -| `types.ts` | All type definitions | -| `messages.ts` | `convertToLlm()`, custom message helpers | -| `system-prompt.ts` | System prompt building | -| `skills.ts` | Skill management | diff --git a/src/llmfunction.jl b/src/llmfunction.jl index 0d42110..55ef96f 100644 --- a/src/llmfunction.jl +++ b/src/llmfunction.jl @@ -330,17 +330,17 @@ function search_wine_database!(a::T, thoughtdict::AbstractDict; useSQLLLM::Bool= pg_conn_str = "host=$host_url port=$port dbname=$dbname user=$user password=$password" #WORKING - df = GeneralUtils.find_text_vector_similarity( - vector_search_str, - "wine", - "tasting_notes_embedding", - GeneralUtils.execute_postgres_sql(pg_conn_str, sql), #BUG input pair (F, arg) - a.context.getTextEmbedding([vector_search_str]) #BUG input pair (F, arg) - ) + # df = GeneralUtils.find_text_vector_similarity( + # vector_search_str, + # "wine", + # "tasting_notes_embedding", + # GeneralUtils.execute_postgres_sql(pg_conn_str, sql), #BUG input pair (F, arg) + # a.context.getTextEmbedding([vector_search_str]) #BUG input pair (F, arg) + # ) - @show df - error(888888) + # @show df + # error(888888) items = nothing -- 2.52.0 From 4d6cdbcf4a9761e5482790ac060b87da50aea36d Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 26 Jul 2026 22:42:09 +0700 Subject: [PATCH 7/7] update --- src/type.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/type.jl b/src/type.jl index f960b78..d554f2a 100644 --- a/src/type.jl +++ b/src/type.jl @@ -226,7 +226,7 @@ function sommelier( return newAgent end - + mutable struct virtualcustomer <: agent name::String # agent name -- 2.52.0