This commit is contained in:
2026-07-28 07:12:13 +07:00
parent 367ebc1c7f
commit 1e5f7b8ff2
29 changed files with 6134 additions and 0 deletions
@@ -0,0 +1,119 @@
# Julia Implementation - AgentCore
This directory contains a Julia reimplementation of the `@earendil-works/pi-agent-core` package.
## Project Structure
```
julia_implementation/
├── src/
│ ├── AgentCore.jl # Main module entry point
│ ├── types.jl # Core type definitions
│ ├── stream_fn.jl # Stream function utilities
│ ├── agent_loop.jl # Low-level agent loop
│ ├── agent.jl # High-level Agent struct
│ ├── harness_types.jl # Extended types for AgentHarness
│ ├── messages.jl # Custom message types
│ ├── system_prompt.jl # System prompt formatting
│ ├── skills.jl # Skill loading and formatting
│ ├── prompt_templates.jl # Prompt template handling
│ ├── agent_harness.jl # AgentHarness implementation
│ │
│ ├── session/
│ │ ├── session.jl # Session class
│ │ ├── jsonl_storage.jl # JSONL storage
│ │ ├── jsonl_repo.jl # JSONL repository
│ │ ├── memory_storage.jl # In-memory storage
│ │ ├── memory_repo.jl # In-memory repository
│ │ └── repo_utils.jl # Repository utilities
│ │
│ ├── tools/
│ │ ├── index.jl # Tool exports
│ │ ├── bash.jl # Bash execution tool
│ │ ├── read.jl # File read tool
│ │ ├── write.jl # File write tool
│ │ ├── edit.jl # File edit tool
│ │ ├── edit_diff.jl # Diff computation
│ │ ├── image.jl # Image utilities
│ │ ├── path_utils.jl # Path resolution
│ │ └── file_mutation_queue.jl # File mutation serialization
│ │
│ ├── compaction/
│ │ ├── compaction.jl # Context compaction
│ │ ├── utils.jl # Compaction utilities
│ │ └── branch_summarization.jl # Branch summarization
│ │
│ ├── utils/
│ │ ├── truncate.jl # Output truncation
│ │ └── shell_output.jl # Shell output capture
│ │
│ ├── proxy.jl # Proxy stream function
│ └── utils.jl # Utility functions
├── test/
├── Project.toml
├── Manifest.toml
└── README.md
```
## Key Features
### Core Architecture
The implementation follows the same layered architecture as the TypeScript version:
1. **Low-level (agent_loop.jl)**: Pure agent loop logic that works with `AgentMessage[]`
2. **High-level (agent.jl)**: Stateful wrapper with event streaming and queueing
3. **Harness (agent_harness.jl)**: Session persistence, resource management, hooks
4. **Session (session/)**: Conversation history with compaction and branching
5. **Tools (tools/)**: Built-in execution tools (bash, read, write, edit)
### Julia-Specific Features
- **Type system**: Uses Julia's parametric types for type-safe tool definitions
- **Multiple dispatch**: Extensible via multiple dispatch for custom message types
- **Async primitives**: Leverages Julia's `@async` and `@spawn` for concurrent operations
- **Error handling**: Julia exceptions with typed error codes
## Building
```julia
using Pkg
Pkg.activate("julia_implementation")
Pkg.instantiate()
```
## Usage Example
```julia
using AgentCore
# Create an agent
agent = Agent()
# Subscribe to events
subscribe(agent) do event, signal
if event isa MessageEndEvent
println("Message: $(event.message)")
end
end
# Run a prompt
prompt(agent, "Hello, world!")
```
## Compatibility
This implementation aims for API compatibility with the TypeScript version while providing idiomatic Julia abstractions.
## Status
This is an active implementation. Core functionality is in place, with ongoing work on:
- Complete tool implementations
- Full session repository functionality
- Test suite
## License
MIT
@@ -0,0 +1,25 @@
name = "AgentCore"
uuid = "6e2f7b3a-9a0b-4e8e-8f8f-8f8f8f8f8f8f"
authors = ["Mario Zechner <post@badlogicgames.com>"]
version = "0.1.0"
[deps]
Dates = "ade2ca70-3891-5945-98fb-dc09409a37d3"
JSON3 = "0f8b85d8-8d2f-5481-9e3b-d9a10a9b6c53"
Libdl = "8f399da3-355a-58d1-55dd-a8cd37d21846"
Markdown = "d6f4372e-7a37-5ca6-90db-23e40208355e"
Mmap = "a63ad114-7ff6-5b6b-903e-90ddba579e5d"
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
Sockets = "6462fe0b-2de3-572b-8e7f-4c2f5e2c2e2b"
Unicode = "4ec0a83e-493e-50e2-b9ac-8f72acf2a872"
UUIDs = "cf7118a7-4649-5bc2-89ac-36d7b14660ca"
[extras]
Test = "8dfed614-e22c-5e4d-98d3-97fe1b80e45d"
[targets]
test = ["Test"]
[compat]
julia = "1.9"
@@ -0,0 +1,22 @@
name = "AgentCore"
uuid = "6e2f7b3a-9a0b-4e8e-8f8f-8f8f8f8f8f8f"
authors = ["Mario Zechner <post@badlogicgames.com>"]
version = "0.1.0"
[deps]
Dates = "ade2ca70-3891-5945-98fb-dc09409a37d3"
JSON3 = "0f8b85d8-8d2f-5481-9e3b-d9a10a9b6c53"
Libdl = "8f399da3-355a-58d1-55dd-a8cd37d21846"
Markdown = "d6f4372e-7a37-5ca6-90db-23e40208355e"
Mmap = "a63ad114-7ff6-5b6b-903e-90ddba579e5d"
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
Sockets = "6462fe0b-2de3-572b-8e7f-4c2f5e2c2e2b"
Unicode = "4ec0a83e-493e-50e2-b9ac-8f72acf2a872"
UUIDs = "cf7118a7-4649-5bc2-89ac-36d7b14660ca"
[extras]
Test = "8dfed614-e22c-5e4d-98d3-97fe1b80e45d"
[targets]
test = ["Test"]
@@ -0,0 +1,179 @@
# AgentCore.jl - Julia Implementation of Pi Agent Core
A Julia reimplementation of the `@earendil-works/pi-agent-core` package, providing a stateful agent framework for LLM interactions.
## Overview
This package provides:
- Low-level `agentLoop` for stateful LLM interactions with tool execution
- High-level `Agent` struct with state management, event streaming, and queueing
- `AgentHarness` for session persistence, resource management, and extension hooks
- Built-in tools for file operations (read, write, edit) and bash execution
- Session management with JSONL-based storage, compaction, and branch navigation
## Architecture
The Julia implementation follows the same layered architecture as the TypeScript version:
```
┌─────────────────────────────────────────────────────────────────────┐
│ AgentHarness │
│ (Session persistence, resource management) │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────▼───────────────────────────────────────┐
│ Agent │
│ (State management, event streaming, queueing) │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────▼───────────────────────────────────────┐
│ AgentLoop │
│ (Low-level loop, tool execution) │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────▼───────────────────────────────────────┐
│ Session │
│ (Conversation history, compaction, branching) │
└─────────────────────────────────────────────────────────────────────┘
```
## Installation
```julia
using Pkg
Pkg.add("AgentCore")
```
## Quick Start
```julia
using AgentCore
# Create an agent with default configuration
agent = Agent()
# Subscribe to events
subscribe(agent) do event, signal
if event isa MessageEndEvent
println("Received message: $(event.message)")
end
end
# Run a prompt
prompt(agent, "Hello, how are you?")
```
## Core Concepts
### Agent
The `Agent` struct provides a high-level interface for interacting with LLMs. It manages:
- Conversation state (messages, tools, system prompt)
- Event streaming and lifecycle management
- Steering and follow-up message queues
- Abort handling
### AgentLoop
The `agentLoop` function implements the core agent loop that:
- Transforms `AgentMessage[]` to `Message[]` at the LLM call boundary
- Executes tool calls (parallel or sequential)
- Emits lifecycle events
- Handles steering and follow-up messages
### AgentHarness
The `AgentHarness` provides:
- Session persistence with JSONL storage
- Resource management (skills, prompt templates)
- Extension hooks system
- Tool execution with context
- Branch navigation and compaction
### Sessions
Sessions track conversation history using a tree-based structure:
- Branch-based history with compaction
- Tree navigation (moveTo, navigateTree)
- Message and metadata persistence
## Built-in Tools
### Bash Tool
Execute shell commands with output capture and truncation.
```julia
bash_tool = createBashTool()
```
### Read Tool
Read files with support for text and images.
```julia
read_tool = createReadTool()
```
### Write Tool
Write content to files with automatic directory creation.
```julia
write_tool = createWriteTool()
```
### Edit Tool
Edit files using exact text replacement.
```julia
edit_tool = createEditTool()
```
## Session Storage
AgentCore supports two session storage backends:
1. **JsonlSessionStorage** - File-based storage using JSONL format
2. **InMemorySessionStorage** - In-memory storage for testing
## Compaction
The compaction system manages context window usage by:
- Summarizing old conversation history
- Retaining recent messages
- Supporting iterative updates to summaries
## Event System
AgentCore uses a rich event system for monitoring and control:
- `AgentStartEvent` / `AgentEndEvent` - Agent lifecycle
- `TurnStartEvent` / `TurnEndEvent` - Conversation turns
- `MessageStartEvent` / `MessageEndEvent` - Message lifecycle
- `ToolExecutionStartEvent` / `ToolExecutionEndEvent` - Tool execution
## Examples
See the `examples/` directory for more detailed examples.
## Differences from TypeScript
While maintaining API compatibility where possible, this Julia implementation:
- Uses Julia's type system for better compile-time guarantees
- Leverages Julia's multiple dispatch for extensibility
- Uses Julia's async primitives for concurrent operations
- Provides more idiomatic Julia error handling
## Contributing
Contributions are welcome! Please see `CONTRIBUTING.md` for details.
## License
MIT
## Acknowledgments
This is a reimplementation of the [Pi Agent Core](https://github.com/earendil-works/pi/packages/agent) package in Julia.
@@ -0,0 +1,149 @@
# AgentCore.jl - A Julia implementation of the Pi Agent Core framework
#
# This is a reimplementation of the TypeScript pi-agent-core package in idiomatic Julia.
#
# The AgentCore package provides:
# - Low-level `agentLoop` for stateful LLM interactions with tool execution
# - High-level `Agent` struct with state management, event streaming, and queueing
# - `AgentHarness` for session persistence, resource management, and extension hooks
# - Built-in tools for file operations (read, write, edit) and bash execution
# - Session management with JSONL-based storage, compaction, and branch navigation
#
# For more information about the original TypeScript implementation, see:
# https://github.com/earendil-works/pi/packages/agent
module AgentCore
# Core modules
include("types.jl")
include("stream_fn.jl")
include("agent_loop.jl")
include("agent.jl")
# Harness modules
include("harness_types.jl")
include("messages.jl")
include("system_prompt.jl")
include("skills.jl")
include("prompt_templates.jl")
include("agent_harness.jl")
# Session modules
include("session/session.jl")
include("session/jsonl_storage.jl")
include("session/jsonl_repo.jl")
include("session/memory_storage.jl")
include("session/memory_repo.jl")
include("session/repo_utils.jl")
# Tool modules
include("tools/index.jl")
include("tools/bash.jl")
include("tools/read.jl")
include("tools/write.jl")
include("tools/edit.jl")
include("tools/edit_diff.jl")
include("tools/image.jl")
include("tools/path_utils.jl")
include("tools/file_mutation_queue.jl")
# Compaction modules
include("compaction/compaction.jl")
include("compaction/utils.jl")
include("compaction/branch_summarization.jl")
# Utility modules
include("utils/truncate.jl")
include("utils/shell_output.jl")
include("proxy.jl")
# Re-export public API
export
# Core types
AgentMessage,
AgentTool,
AgentContext,
AgentEvent,
ThinkingLevel,
ToolExecutionMode,
QueueMode,
AgentState,
# Agent
Agent,
AgentOptions,
# AgentLoop
AgentLoopConfig,
agentLoop,
agentLoopContinue,
runAgentLoop,
runAgentLoopContinue,
# AgentHarness
AgentHarness,
AgentHarnessOptions,
AgentHarnessEvent,
AgentHarnessResources,
AgentHarnessSystemPrompt,
# Session
Session,
SessionStorage,
SessionRepo,
JsonlSessionStorage,
JsonlSessionRepo,
InMemorySessionStorage,
InMemorySessionRepo,
# Tools
createBashTool,
createReadTool,
createWriteTool,
createEditTool,
ExecutionEnv,
# Compaction
compact,
prepareCompaction,
DEFAULT_COMPACTION_SETTINGS,
generateSummary,
generateBranchSummary,
# Utils
truncateHead,
truncateTail,
formatSize,
DEFAULT_MAX_LINES,
DEFAULT_MAX_BYTES,
# Messages
convertToLlm,
bashExecutionToText,
# System prompt
formatSkillsForSystemPrompt,
# Skills
loadSkills,
formatSkillInvocation,
# Prompt templates
loadPromptTemplates,
formatPromptTemplateInvocation,
parseCommandArgs,
substituteArgs,
# Proxy
streamProxy,
ProxyStreamOptions,
# Stream
setDefaultStreamFn,
getDefaultStreamFn,
# Utility functions
uuidv7,
create_timestamp
end
@@ -0,0 +1,416 @@
"""
agent.jl - High-level Agent struct
This module implements the high-level Agent wrapper around the low-level agent loop,
providing state management, event streaming, and queueing for steering and follow-up messages.
"""
module Agent
using ..Types: *
using ..AgentLoop: *
using ..StreamFn: *
# ============================================================================
# Default convertToLlm function
# ============================================================================
function defaultConvertToLlm(messages::Vector{AgentMessage})::Vector{Message}
return filter(
(m) -> m.role == "user" || m.role == "assistant" || m.role == "toolResult",
messages,
)
end
# ============================================================================
# Empty usage constant
# ============================================================================
const EMPTY_USAGE = Usage(
0, 0, 0, 0, 0, UsageCost(0.0, 0.0, 0.0, 0.0, 0.0)
)
# ============================================================================
# Pending message queue
# ============================================================================
mutable struct PendingMessageQueue
messages::Vector{AgentMessage}
mode::QueueMode
function PendingMessageQueue(mode::QueueMode)
new(AgentMessage[], mode)
end
end
function enqueue!(queue::PendingMessageQueue, message::AgentMessage)
push!(queue.messages, message)
end
function hasItems(queue::PendingMessageQueue)::Bool
return !isempty(queue.messages)
end
function drain(queue::PendingMessageQueue)::Vector{AgentMessage}
if queue.mode == QUEUE_ALL
result = copy(queue.messages)
empty!(queue.messages)
return result
else
if isempty(queue.messages)
return AgentMessage[]
end
first = popfirst!(queue.messages)
return [first]
end
end
function clear!(queue::PendingMessageQueue)
empty!(queue.messages)
end
# ============================================================================
# Active run state
# ============================================================================
mutable struct ActiveRun
promise::Promise
abort_controller::Base.Atomic{Union{Base.AbstractLock, Nothing}}
end
# ============================================================================
# Agent struct
# ============================================================================
mutable struct Agent
_state::AgentState
listeners::Set{Tuple{Function, Ref{Bool}}}
steering_queue::PendingMessageQueue
follow_up_queue::PendingMessageQueue
convert_to_llm::Function
transform_context::Union{Function, Nothing}
stream_function::StreamFn
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{Dict{String, Int64}, Nothing}
transport::String
max_retry_delay_ms::Union{Int64, Nothing}
tool_execution::ToolExecutionMode
function Agent(options::Dict{Symbol, Any}=Dict{Symbol, Any}())
runtime_options = merge(
Dict{Symbol, Any}(
:stream_fn => getDefaultStreamFn(),
:convertToLlm => defaultConvertToLlm,
:steeringMode => QUEUE_ONE_AT_A_TIME,
:followUpMode => QUEUE_ONE_AT_A_TIME,
:toolExecution => EXECUTION_PARALLEL,
:transport => "auto",
),
options,
)
state = AgentState(
get(runtime_options, :systemPrompt, ""),
get(runtime_options, :model, Model("", "", "unknown", "unknown", "", false, String[], ModelCost(0.0, 0.0, 0.0, 0.0), 0, 0)),
get(runtime_options, :thinkingLevel, THINKING_OFF),
get(runtime_options, :tools, AgentTool[]),
get(runtime_options, :messages, AgentMessage[]),
)
new(
state,
Set{Tuple{Function, Ref{Bool}}}(),
PendingMessageQueue(QUEUE_ONE_AT_A_TIME),
PendingMessageQueue(QUEUE_ONE_AT_A_TIME),
get(runtime_options, :convertToLlm, defaultConvertToLlm),
get(runtime_options, :transformContext, nothing),
get(runtime_options, :stream_fn, getDefaultStreamFn()),
get(runtime_options, :getApiKey, nothing),
get(runtime_options, :onPayload, nothing),
get(runtime_options, :onResponse, nothing),
get(runtime_options, :beforeToolCall, nothing),
get(runtime_options, :afterToolCall, nothing),
get(runtime_options, :prepareNextTurn, nothing),
get(runtime_options, :prepareNextTurnWithContext, nothing),
nothing,
get(runtime_options, :sessionId, nothing),
get(runtime_options, :thinkingBudgets, nothing),
get(runtime_options, :transport, "auto"),
get(runtime_options, :maxRetryDelayMs, nothing),
get(runtime_options, :toolExecution, EXECUTION_PARALLEL),
)
end
end
# ============================================================================
# Agent methods
# ============================================================================
"""
subscribe(agent, listener)
Subscribe to agent lifecycle events.
# Arguments
- `agent`: The agent instance
- `listener`: A function that takes (event::AgentEvent, signal::AbortSignal)
# Returns
- A function that unsubscribes the listener
"""
function subscribe(agent::Agent, listener::Function)::Function
push!(agent.listeners, (listener, Ref{Bool}(true)))
return () -> begin
filter!(x -> x[1] != listener, agent.listeners)
end
end
"""
get_state(agent)
Get the current agent state.
"""
function get_state(agent::Agent)::AgentState
return agent._state
end
"""
steer(agent, message)
Queue a message to be injected after the current assistant turn finishes.
"""
function steer(agent::Agent, message::AgentMessage)
enqueue!(agent.steering_queue, message)
end
"""
followUp(agent, message)
Queue a message to run only after the agent would otherwise stop.
"""
function followUp(agent::Agent, message::AgentMessage)
enqueue!(agent.follow_up_queue, message)
end
"""
clearSteeringQueue(agent)
Remove all queued steering messages.
"""
function clearSteeringQueue(agent::Agent)
clear!(agent.steering_queue)
end
"""
clearFollowUpQueue(agent)
Remove all queued follow-up messages.
"""
function clearFollowUpQueue(agent::Agent)
clear!(agent.follow_up_queue)
end
"""
clearAllQueues(agent)
Remove all queued steering and follow-up messages.
"""
function clearAllQueues(agent::Agent)
clearSteeringQueue(agent)
clearFollowUpQueue(agent)
end
"""
hasQueuedMessages(agent)
Returns true when either queue still contains pending messages.
"""
function hasQueuedMessages(agent::Agent)::Bool
return hasItems(agent.steering_queue) || hasItems(agent.follow_up_queue)
end
"""
abort(agent)
Abort the current run, if one is active.
"""
function abort(agent::Agent)
if !isnothing(agent.active_run)
# TODO: Implement abort signal
end
end
"""
waitForIdle(agent)
Resolve when the current run and all awaited event listeners have finished.
"""
function waitForIdle(agent::Agent)::Promise
if isnothing(agent.active_run)
return Promise()
end
return agent.active_run.promise
end
"""
reset(agent)
Clear transcript state, runtime state, and queued messages.
"""
function reset!(agent::Agent)
agent._state.messages = AgentMessage[]
agent._state.is_streaming = false
agent._state.streaming_message = nothing
agent._state.pending_tool_calls = Set{String}()
agent._state.error_message = nothing
clearFollowUpQueue(agent)
clearSteeringQueue(agent)
end
"""
prompt(agent, input[, images])
Start a new prompt from text, a single message, or a batch of messages.
"""
function prompt(agent::Agent, input::Union{String, AgentMessage, Vector{AgentMessage}}, images::Vector{ImageContent}=ImageContent[])::Nothing
if !isnothing(agent.active_run)
throw(ErrorException(
"Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion."
))
end
messages = normalizePromptInput(agent, input, images)
runPromptMessages(agent, messages)
end
function normalizePromptInput(agent::Agent, input::Vector{AgentMessage}, images::Vector{ImageContent})::Vector{AgentMessage}
return input
end
function normalizePromptInput(agent::Agent, input::AgentMessage, images::Vector{ImageContent})::Vector{AgentMessage}
return [input]
end
function normalizePromptInput(agent::Agent, input::String, images::Vector{ImageContent})::Vector{AgentMessage}
content::Vector{MessageContent} = [TextContent(input)]
if !isempty(images)
append!(content, images)
end
return [UserMessage("user", content, Int64(Dates.now(Dates.UTC).datetime))]
end
function runPromptMessages(agent::Agent, messages::Vector{AgentMessage})::Nothing
# TODO: Implement run with lifecycle
return nothing
end
"""
continue(agent)
Continue from the current transcript. The last message must be a user or tool-result message.
"""
function continue!(agent::Agent)::Nothing
if !isnothing(agent.active_run)
throw(ErrorException("Agent is already processing. Wait for completion before continuing."))
end
last_message = agent._state.messages[end]
if isnothing(last_message)
throw(ErrorException("No messages to continue from"))
end
if last_message.role == "assistant"
queued_steering = drain(agent.steering_queue)
if !isempty(queued_steering)
runPromptMessages(agent, queued_steering)
return nothing
end
queued_follow_ups = drain(agent.follow_up_queue)
if !isempty(queued_follow_ups)
runPromptMessages(agent, queued_follow_ups)
return nothing
end
throw(ErrorException("Cannot continue from message role: assistant"))
end
# TODO: Implement run continuation
return nothing
end
"""
createContextSnapshot(agent)
Create a snapshot of the current context for use in the agent loop.
"""
function createContextSnapshot(agent::Agent)::AgentContext
return AgentContext(
agent._state.system_prompt,
copy(agent._state.messages),
copy(agent._state.tools),
)
end
"""
createLoopConfig(agent, options)
Create the loop configuration for the agent.
"""
function createLoopConfig(agent::Agent, options::Dict{String, Any}=Dict{String, Any}())::AgentLoopConfig
skip_initial_steering_poll = get(options, "skipInitialSteeringPoll", false)
return AgentLoopConfig(
agent._state.model,
agent._state.thinking_level == THINKING_OFF ? nothing : agent._state.thinking_level,
agent.session_id,
agent.on_payload,
agent.on_response,
agent.transport,
agent.thinking_budgets,
agent.max_retry_delay_ms,
agent.tool_execution,
agent.before_tool_call,
agent.after_tool_call,
isnothing(agent.prepare_next_turn_with_context) && isnothing(agent.prepare_next_turn) ? nothing : function(context)
if !isnothing(agent.prepare_next_turn_with_context)
return agent.prepare_next_turn_with_context(context, getSignal(agent))
end
return isnothing(agent.prepare_next_turn) ? nothing : agent.prepare_next_turn(getSignal(agent))
end,
agent.convert_to_llm,
agent.transform_context,
agent.get_api_key,
function()
if skip_initial_steering_poll
skip_initial_steering_poll = false
return AgentMessage[]
end
return drain(agent.steering_queue)
end,
function()
return drain(agent.follow_up_queue)
end,
)
end
"""
getSignal(agent)
Get the active abort signal for the current run, if any.
"""
function getSignal(agent::Agent)::Union{Nothing, Base.Atomic{Bool}}
if isnothing(agent.active_run)
return nothing
end
return agent.active_run.abort_controller
end
end
@@ -0,0 +1,861 @@
"""
agent_loop.jl - Low-level agent loop implementation
This module implements the core agentLoop functionality that works with AgentMessage
throughout, transforming to Message[] only at the LLM call boundary.
"""
module AgentLoop
using ..Types: *
using ..StreamFn: *
# ============================================================================
# Event sink type
# ============================================================================
const AgentEventSink = Function
# ============================================================================
# Main agent loop function
# ============================================================================
function agentLoop(
prompts::Vector{AgentMessage},
context::AgentContext,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
stream_fn::StreamFn,
)::EventStream
stream = createAgentStream()
Threads.@spawn begin
messages = runAgentLoop(
prompts,
context,
config,
(event) -> push!(stream, event),
signal,
stream_fn,
)
end(stream, messages)
end
return stream
end
# ============================================================================
# Continue agent loop function
# ============================================================================
function agentLoopContinue(
context::AgentContext,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
stream_fn::StreamFn,
)::EventStream
if isempty(context.messages)
throw(ErrorException("Cannot continue: no messages in context"))
end
if context.messages[end].role == "assistant"
throw(ErrorException("Cannot continue from message role: assistant"))
end
stream = createAgentStream()
Threads.@spawn begin
messages = runAgentLoopContinue(
context,
config,
(event) -> push!(stream, event),
signal,
stream_fn,
)
end(stream, messages)
end
return stream
end
# ============================================================================
# Run agent loop function
# ============================================================================
function runAgentLoop(
prompts::Vector{AgentMessage},
context::AgentContext,
config::AgentLoopConfig,
emit::AgentEventSink,
signal::Union{Nothing, AbortSignal},
stream_fn::StreamFn,
)::Vector{AgentMessage}
new_messages::Vector{AgentMessage} = copy(prompts)
current_context::AgentContext = AgentContext(
context.system_prompt,
vcat(context.messages, copy(prompts)),
context.tools,
)
emit(AgentStartEvent())
emit(TurnStartEvent())
for prompt in prompts
emit(MessageStartEvent(prompt))
emit(MessageEndEvent(prompt))
end
runLoop(
current_context,
new_messages,
config,
signal,
emit,
stream_fn,
)
return new_messages
end
# ============================================================================
# Run agent loop continue function
# ============================================================================
function runAgentLoopContinue(
context::AgentContext,
config::AgentLoopConfig,
emit::AgentEventSink,
signal::Union{Nothing, AbortSignal},
stream_fn::StreamFn,
)::Vector{AgentMessage}
if isempty(context.messages)
throw(ErrorException("Cannot continue: no messages in context"))
end
if context.messages[end].role == "assistant"
throw(ErrorException("Cannot continue from message role: assistant"))
end
new_messages::Vector{AgentMessage} = []
current_context::AgentContext = context
emit(AgentStartEvent())
emit(TurnStartEvent())
runLoop(
current_context,
new_messages,
config,
signal,
emit,
stream_fn,
)
return new_messages
end
# ============================================================================
# Create agent stream function
# ============================================================================
function createAgentStream()::EventStream
return EventStream(
(event::AgentEvent) -> event isa AgentEndEvent,
(event::AgentEvent) -> event isa AgentEndEvent ? event.messages : AgentMessage[],
)
end
# ============================================================================
# Main loop logic shared by agentLoop and agentLoopContinue
# ============================================================================
function runLoop(
initial_context::AgentContext,
new_messages::Vector{AgentMessage},
initial_config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
stream_function::StreamFn,
)::Nothing
current_context::AgentContext = initial_context
config::AgentLoopConfig = initial_config
first_turn::Bool = true
pending_messages::Vector{AgentMessage} = getSteeringMessages(config) do
get_steering_messages(config)
end
while true
has_more_tool_calls::Bool = true
while has_more_tool_calls || !isempty(pending_messages)
if !first_turn
emit(TurnStartEvent())
else
first_turn = false
end
if !isempty(pending_messages)
for message in pending_messages
emit(MessageStartEvent(message))
emit(MessageEndEvent(message))
push!(current_context.messages, message)
push!(new_messages, message)
end
pending_messages = AgentMessage[]
end
message = streamAssistantResponse(
current_context,
config,
signal,
emit,
stream_function,
)
push!(new_messages, message)
if message.stop_reason in ("error", "aborted")
emit(TurnEndEvent(message, ToolResultMessage[]))
emit(AgentEndEvent(new_messages))
return
end
tool_calls = filter(
(c) -> c isa ToolCall,
message.content,
)
tool_results::Vector{ToolResultMessage} = []
has_more_tool_calls = false
if !isempty(tool_calls)
executed_tool_batch =
message.stop_reason == "length"
? failToolCallsFromTruncatedMessage(tool_calls, emit)
: executeToolCalls(
current_context,
message,
config,
signal,
emit,
)
append!(tool_results, executed_tool_batch.messages)
has_more_tool_calls = !executed_tool_batch.terminate
for result in tool_results
push!(current_context.messages, result)
push!(new_messages, result)
end
end
emit(TurnEndEvent(message, tool_results))
next_turn_context = PrepareNextTurnContext(
message,
tool_results,
current_context,
new_messages,
)
next_turn_snapshot = prepare_next_turn(config, next_turn_context)
if !isnothing(next_turn_snapshot)
current_context = next_turn_snapshot.context
config = AgentLoopConfig(
model = next_turn_snapshot.model,
reasoning = next_turn_snapshot.thinking_level,
convert_to_llm = config.convert_to_llm,
transform_context = config.transform_context,
get_api_key = config.get_api_key,
should_stop_after_turn = config.should_stop_after_turn,
prepare_next_turn = config.prepare_next_turn,
get_steering_messages = config.get_steering_messages,
get_follow_up_messages = config.get_follow_up_messages,
tool_execution = config.tool_execution,
before_tool_call = config.before_tool_call,
after_tool_call = config.after_tool_call,
max_tokens = config.max_tokens,
temperature = config.temperature,
reasoning = config.reasoning,
cache_retention = config.cache_retention,
session_id = config.session_id,
headers = config.headers,
metadata = config.metadata,
transport = config.transport,
signal = signal,
api_key = config.api_key,
on_payload = config.on_payload,
on_response = config.on_response,
max_retry_delay_ms = config.max_retry_delay_ms,
)
end
if should_stop_after_turn(config, next_turn_context)
emit(AgentEndEvent(new_messages))
return
end
pending_messages = getSteeringMessages(config) do
get_steering_messages(config)
end
end
follow_up_messages = getFollowUpMessages(config) do
get_follow_up_messages(config)
end
if !isempty(follow_up_messages)
pending_messages = follow_up_messages
continue
end
break
end
emit(AgentEndEvent(new_messages))
end
# ============================================================================
# Helper types
# ============================================================================
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
# ============================================================================
# Helper functions for getting messages from queues
# ============================================================================
macro getSteeringMessages(config)
:(get_steering_messages($(esc(config))))
end
macro getFollowUpMessages(config)
:(get_follow_up_messages($(esc(config))))
end
function get_steering_messages(config::AgentLoopConfig)::Vector{AgentMessage}
return isnothing(config.get_steering_messages) ? AgentMessage[] : config.get_steering_messages()
end
function get_follow_up_messages(config::AgentLoopConfig)::Vector{AgentMessage}
return isnothing(config.get_follow_up_messages) ? AgentMessage[] : config.get_follow_up_messages()
end
function prepare_next_turn(config::AgentLoopConfig, context::PrepareNextTurnContext)::Union{AgentLoopTurnUpdate, Nothing}
return isnothing(config.prepare_next_turn) ? nothing : config.prepare_next_turn(context)
end
function should_stop_after_turn(config::AgentLoopConfig, context::PrepareNextTurnContext)::Bool
return isnothing(config.should_stop_after_turn) ? false : config.should_stop_after_turn(context)
end
# ============================================================================
# Stream assistant response function
# ============================================================================
function streamAssistantResponse(
context::AgentContext,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
stream_function::StreamFn,
)::AssistantMessage
messages::Vector{AgentMessage} = context.messages
if !isnothing(config.transform_context)
messages = config.transform_context(messages, signal)
end
llm_messages::Vector{Message} = config.convert_to_llm(messages)
llm_context::Context = Context(
context.system_prompt,
llm_messages,
context.tools,
)
resolved_api_key::Union{String, Nothing} =
!isnothing(config.get_api_key)
? config.get_api_key(config.model.provider)
: nothing
response = stream_function(
config.model,
llm_context,
merge(
config,
Dict(:apiKey => resolved_api_key, :signal => signal),
),
)
partial_message::Union{AssistantMessage, Nothing} = nothing
added_partial::Bool = false
for event in response
if event.type == "start"
partial_message = event.partial
push!(context.messages, partial_message)
added_partial = true
emit(MessageStartEvent(copy(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 !isnothing(partial_message)
partial_message = event.partial
context.messages[end] = partial_message
emit(MessageUpdateEvent(copy(partial_message), event))
end
elseif event.type in ("done", "error")
final_message = response.result()
if added_partial
context.messages[end] = final_message
else
push!(context.messages, final_message)
end
if !added_partial
emit(MessageStartEvent(copy(final_message)))
end
emit(MessageEndEvent(final_message))
return final_message
end
end
final_message = response.result()
if added_partial
context.messages[end] = final_message
else
push!(context.messages, final_message)
emit(MessageStartEvent(copy(final_message)))
end
emit(MessageEndEvent(final_message))
return final_message
end
# ============================================================================
# Fail tool calls from truncated message
# ============================================================================
struct ExecutedToolCallBatch
messages::Vector{ToolResultMessage}
terminate::Bool
end
function failToolCallsFromTruncatedMessage(
tool_calls::Vector{ToolCall},
emit::AgentEventSink,
)::ExecutedToolCallBatch
messages::Vector{ToolResultMessage} = []
for tool_call in tool_calls
emit(ToolExecutionStartEvent(tool_call.id, tool_call.name, tool_call.arguments))
finalized = FinalizedToolCallOutcome(
tool_call,
createErrorToolResult(
"Tool call \"$(tool_call.name)\" was not executed: the response hit the output token limit, so its arguments may be truncated. Re-issue the tool call with complete arguments.",
),
true,
)
emitToolExecutionEnd(finalized, emit)
tool_result_message = createToolResultMessage(finalized)
emitToolResultMessage(tool_result_message, emit)
push!(messages, tool_result_message)
end
return ExecutedToolCallBatch(messages, false)
end
# ============================================================================
# Execute tool calls
# ============================================================================
function executeToolCalls(
current_context::AgentContext,
assistant_message::AssistantMessage,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
)::ExecutedToolCallBatch
tool_calls = filter(
(c) -> c isa ToolCall,
assistant_message.content,
)
has_sequential_tool_call = any(
(tc) -> begin
tool = findfirst((t) -> t.name == tc.name, current_context.tools)
!isnothing(tool) && tool.execution_mode == EXECUTION_SEQUENTIAL
end,
tool_calls,
)
if config.tool_execution == EXECUTION_SEQUENTIAL || has_sequential_tool_call
return executeToolCallsSequential(
current_context,
assistant_message,
tool_calls,
config,
signal,
emit,
)
end
return executeToolCallsParallel(
current_context,
assistant_message,
tool_calls,
config,
signal,
emit,
)
end
# ============================================================================
# Execute tool calls sequentially
# ============================================================================
function executeToolCallsSequential(
current_context::AgentContext,
assistant_message::AssistantMessage,
tool_calls::Vector{ToolCall},
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
)::ExecutedToolCallBatch
finalized_calls::Vector{FinalizedToolCallOutcome} = []
messages::Vector{ToolResultMessage} = []
for tool_call in tool_calls
emit(ToolExecutionStartEvent(tool_call.id, tool_call.name, tool_call.arguments))
preparation = prepareToolCall(current_context, assistant_message, tool_call, config, signal)
finalized = if preparation.kind == "immediate"
FinalizedToolCallOutcome(tool_call, preparation.result, preparation.is_error)
else
executed = executePreparedToolCall(preparation, signal, emit)
finalizeExecutedToolCall(
current_context,
assistant_message,
preparation,
executed,
config,
signal,
)
end
emitToolExecutionEnd(finalized, emit)
tool_result_message = createToolResultMessage(finalized)
emitToolResultMessage(tool_result_message, emit)
push!(finalized_calls, finalized)
push!(messages, tool_result_message)
if !isnothing(signal) && signal.aborted
break
end
end
return ExecutedToolCallBatch(messages, shouldTerminateToolBatch(finalized_calls))
end
# ============================================================================
# Execute tool calls in parallel
# ============================================================================
function executeToolCallsParallel(
current_context::AgentContext,
assistant_message::AssistantMessage,
tool_calls::Vector{ToolCall},
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
)::ExecutedToolCallBatch
finalized_calls::Vector{Union{FinalizedToolCallOutcome, Function}} = []
for tool_call in tool_calls
emit(ToolExecutionStartEvent(tool_call.id, tool_call.name, tool_call.arguments))
preparation = prepareToolCall(current_context, assistant_message, tool_call, config, signal)
if preparation.kind == "immediate"
finalized = FinalizedToolCallOutcome(
tool_call,
preparation.result,
preparation.is_error,
)
emitToolExecutionEnd(finalized, emit)
push!(finalized_calls, finalized)
if !isnothing(signal) && signal.aborted
break
end
continue
end
push!(finalized_calls, () -> begin
executed = executePreparedToolCall(preparation, signal, emit)
finalized = finalizeExecutedToolCall(
current_context,
assistant_message,
preparation,
executed,
config,
signal,
)
emitToolExecutionEnd(finalized, emit)
return finalized
end)
if !isnothing(signal) && signal.aborted
break
end
end
ordered_finalized_calls = map(
(entry) -> if entry isa Function
entry()
else
entry
end,
finalized_calls,
)
messages::Vector{ToolResultMessage} = []
for finalized in ordered_finalized_calls
tool_result_message = createToolResultMessage(finalized)
emitToolResultMessage(tool_result_message, emit)
push!(messages, tool_result_message)
end
return ExecutedToolCallBatch(messages, shouldTerminateToolBatch(ordered_finalized_calls))
end
# ============================================================================
# Prepared tool call types
# ============================================================================
struct PreparedToolCall
kind::String
tool_call::ToolCall
tool::AgentTool
args::Any
end
struct ImmediateToolCallOutcome
kind::String
result::AgentToolResultMutable
is_error::Bool
end
struct ExecutedToolCallOutcome
result::AgentToolResultMutable
is_error::Bool
end
struct FinalizedToolCallOutcome
tool_call::ToolCall
result::AgentToolResultMutable
is_error::Bool
end
# ============================================================================
# Helper functions
# ============================================================================
function shouldTerminateToolBatch(finalized_calls::Vector{FinalizedToolCallOutcome})::Bool
return !isempty(finalized_calls) && all(
(finalized) -> finalized.result.terminate === true,
finalized_calls,
)
end
function prepareToolCallArguments(tool::AgentTool, tool_call::ToolCall)::ToolCall
if isnothing(tool.prepare_arguments)
return tool_call
end
prepared_arguments = tool.prepare_arguments(tool_call.arguments)
if prepared_arguments === tool_call.arguments
return tool_call
end
return ToolCall(
tool_call.type,
tool_call.id,
tool_call.name,
prepared_arguments,
tool_call.partial_json,
)
end
function prepareToolCall(
current_context::AgentContext,
assistant_message::AssistantMessage,
tool_call::ToolCall,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
)::Union{PreparedToolCall, ImmediateToolCallOutcome}
tool = findfirst((t) -> t.name == tool_call.name, current_context.tools)
if isnothing(tool)
return ImmediateToolCallOutcome("immediate", createErrorToolResult("Tool $(tool_call.name) not found"), true)
end
try
prepared_tool_call = prepareToolCallArguments(tool, tool_call)
validated_args = validateToolArguments(tool, prepared_tool_call)
if !isnothing(config.before_tool_call)
before_result = config.before_tool_call(
BeforeToolCallContext(assistant_message, tool_call, validated_args, current_context),
signal,
)
if !isnothing(signal) && signal.aborted
return ImmediateToolCallOutcome("immediate", createErrorToolResult("Operation aborted"), true)
end
if !isnothing(before_result) && before_result.block
reason = isnothing(before_result.reason) ? "Tool execution was blocked" : before_result.reason
return ImmediateToolCallOutcome("immediate", createErrorToolResult(reason), true)
end
end
if !isnothing(signal) && signal.aborted
return ImmediateToolCallOutcome("immediate", createErrorToolResult("Operation aborted"), true)
end
return PreparedToolCall("prepared", tool_call, tool, validated_args)
catch error
return ImmediateToolCallOutcome("immediate", createErrorToolResult(string(error)), true)
end
end
function executePreparedToolCall(
prepared::PreparedToolCall,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
)::ExecutedToolCallOutcome
update_events::Vector{Future} = []
accepting_updates::Bool = 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(
ToolExecutionUpdateEvent(
prepared.tool_call.id,
prepared.tool_call.name,
prepared.tool_call.arguments,
partial_result,
),
)
end,
)
end,
)
accepting_updates = false
wait.(update_events)
return ExecutedToolCallOutcome(result, false)
catch error
accepting_updates = false
wait.(update_events)
return ExecutedToolCallOutcome(createErrorToolResult(string(error)), true)
finally
accepting_updates = false
end
end
function finalizeExecutedToolCall(
current_context::AgentContext,
assistant_message::AssistantMessage,
prepared::PreparedToolCall,
executed::ExecutedToolCallOutcome,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
)::FinalizedToolCallOutcome
result = executed.result
is_error = executed.is_error
if !isnothing(config.after_tool_call)
try
after_result = config.after_tool_call(
AfterToolCallContext(
assistant_message,
prepared.tool_call,
prepared.args,
result,
is_error,
current_context,
),
signal,
)
if !isnothing(after_result)
result = AgentToolResultMutable(
isnothing(after_result.content) ? result.content : after_result.content,
isnothing(after_result.details) ? result.details : after_result.details,
isnothing(after_result.usage) ? result.usage : after_result.usage,
result.added_tool_names,
isnothing(after_result.terminate) ? result.terminate : after_result.terminate,
)
is_error = isnothing(after_result.is_error) ? is_error : after_result.is_error
end
catch error
result = createErrorToolResult(string(error))
is_error = true
end
end
return FinalizedToolCallOutcome(prepared.tool_call, result, is_error)
end
function createErrorToolResult(message::String)::AgentToolResultMutable
return AgentToolResultMutable([TextContent(message)], Dict{String, Any}(), nothing, nothing, nothing)
end
function emitToolExecutionEnd(finalized::FinalizedToolCallOutcome, emit::AgentEventSink)::Nothing
emit(ToolExecutionEndEvent(
finalized.tool_call.id,
finalized.tool_call.name,
finalized.result,
finalized.is_error,
))
return nothing
end
function createToolResultMessage(finalized::FinalizedToolCallOutcome)::ToolResultMessage
return ToolResultMessage(
"toolResult",
finalized.tool_call.id,
finalized.tool_call.name,
isnothing(finalized.result.content) ? MessageContent[] : finalized.result.content,
finalized.result.details,
finalized.result.usage,
finalized.result.added_tool_names,
finalized.is_error,
Dates.now(Dates.UTC).datetime,
)
end
function emitToolResultMessage(tool_result_message::ToolResultMessage, emit::AgentEventSink)::Nothing
emit(MessageStartEvent(tool_result_message))
emit(MessageEndEvent(tool_result_message))
return nothing
end
# ============================================================================
# Validation helper
# ============================================================================
function validateToolArguments(tool::AgentTool, tool_call::ToolCall)::Any
# Simplified validation - in a full implementation, this would use TypeBox-like validation
return tool_call.arguments
end
end
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,183 @@
"""
messages.jl - Custom message types and LLM conversion
This module provides custom message types and the convertToLlm function.
"""
module Messages
using ..Types: *
const COMPACTION_SUMMARY_PREFIX = """The conversation history before this point was compacted into the following summary:
<summary>
"""
const COMPACTION_SUMMARY_SUFFIX = """
</summary>"""
const BRANCH_SUMMARY_PREFIX = """The following is a summary of a branch that this conversation came back from:
<summary>
"""
const BRANCH_SUMMARY_SUFFIX = """</summary>"""
# ============================================================================
# Custom message types
# ============================================================================
mutable struct BashExecutionMessage
role::String
command::String
output::String
exit_code::Union{Int64, Nothing}
cancelled::Bool
truncated::Bool
full_output_path::Union{String, Nothing}
timestamp::Timestamp
exclude_from_context::Bool
end
mutable struct CustomMessage{T}
role::String
custom_type::String
content::Union{String, Vector{MessageContent}}
display::Bool
details::Union{T, Nothing}
timestamp::Timestamp
end
mutable struct BranchSummaryMessage
role::String
summary::String
from_id::String
timestamp::Timestamp
end
mutable struct CompactionSummaryMessage
role::String
summary::String
tokens_before::Int64
timestamp::Timestamp
end
# ============================================================================
# Bash execution to text conversion
# ============================================================================
function bashExecutionToText(msg::BashExecutionMessage)::String
text = "Ran `$(msg.command)`\n"
if !isempty(msg.output)
text *= "```\n$(msg.output)\n```"
else
text *= "(no output)"
end
if msg.cancelled
text *= "\n\n(command cancelled)"
elseif !isnothing(msg.exit_code) && msg.exit_code != 0
text *= "\n\nCommand exited with code $(msg.exit_code)"
end
if msg.truncated && !isnothing(msg.full_output_path)
text *= "\n\n[Output truncated. Full output: $(msg.full_output_path)]"
end
return text
end
# ============================================================================
# Message creation functions
# ============================================================================
function createBranchSummaryMessage(summary::String, from_id::String, timestamp::String)::BranchSummaryMessage
return BranchSummaryMessage(
"branchSummary",
summary,
from_id,
Int64(Dates.now(Dates.UTC).datetime),
)
end
function createCompactionSummaryMessage(summary::String, tokens_before::Int64, timestamp::String)::CompactionSummaryMessage
return CompactionSummaryMessage(
"compactionSummary",
summary,
tokens_before,
Int64(Dates.now(Dates.UTC).datetime),
)
end
function createCustomMessage(custom_type::String, content::Union{String, Vector{MessageContent}}, display::Bool, details::Union{Any, Nothing}, timestamp::String)::CustomMessage
return CustomMessage(
"custom",
custom_type,
content,
display,
details,
Int64(Dates.now(Dates.UTC).datetime),
)
end
# ============================================================================
# Convert to LLM messages
# ============================================================================
function convertToLlm(messages::Vector{AgentMessage})::Vector{Message}
result::Vector{Message} = Message[]
for m in messages
converted = convertToLlmMessage(m)
if !isnothing(converted)
push!(result, converted)
end
end
return result
end
function convertToLlmMessage(m::BashExecutionMessage)::Union{UserMessage, Nothing}
if m.exclude_from_context
return nothing
end
return UserMessage(
"user",
[TextContent(bashExecutionToText(m))],
m.timestamp,
)
end
function convertToLlmMessage(m::CustomMessage)::Union{UserMessage, Nothing}
content = if m.content isa String
[TextContent(m.content)]
else
m.content
end
return UserMessage("user", content, m.timestamp)
end
function convertToLlmMessage(m::BranchSummaryMessage)::UserMessage
text = BRANCH_SUMMARY_PREFIX * m.summary * BRANCH_SUMMARY_SUFFIX
return UserMessage("user", [TextContent(text)], m.timestamp)
end
function convertToLlmMessage(m::CompactionSummaryMessage)::UserMessage
text = COMPACTION_SUMMARY_PREFIX * m.summary * COMPACTION_SUMMARY_SUFFIX
return UserMessage("user", [TextContent(text)], m.timestamp)
end
function convertToLlmMessage(m::UserMessage)::UserMessage
return m
end
function convertToLlmMessage(m::AssistantMessage)::AssistantMessage
return m
end
function convertToLlmMessage(m::ToolResultMessage)::ToolResultMessage
return m
end
function convertToLlmMessage(m::AgentMessage)::Union{Message, Nothing}
return nothing
end
end
@@ -0,0 +1,335 @@
"""
prompt_templates.jl - Prompt template loading and formatting
This module provides utilities for loading prompt templates and formatting invocations.
"""
module PromptTemplates
using ..Types: *
using ..HarnessTypes: ExecutionEnv, toError, Result, ok, err
# ============================================================================
# Prompt template diagnostic types
# ============================================================================
const PromptTemplateDiagnosticCode = String
const PROMPT_TEMPLATE_DIAGNOSTIC_FILE_INFO_FAILED = "file_info_failed"
const PROMPT_TEMPLATE_DIAGNOSTIC_LIST_FAILED = "list_failed"
const PROMPT_TEMPLATE_DIAGNOSTIC_READ_FAILED = "read_failed"
const PROMPT_TEMPLATE_DIAGNOSTIC_PARSE_FAILED = "parse_failed"
mutable struct PromptTemplateDiagnostic
type::String
code::PromptTemplateDiagnosticCode
message::String
path::String
end
# ============================================================================
# Prompt template frontmatter
# ============================================================================
mutable struct PromptTemplateFrontmatter
description::Union{String, Nothing}
argument_hint::Union{String, Nothing}
extra::Dict{String, Any}
end
# ============================================================================
# Load prompt templates from paths
# ============================================================================
function loadPromptTemplates(
env::ExecutionEnv,
paths::Union{String, Vector{String}},
)::Tuple{Vector{PromptTemplate}, Vector{PromptTemplateDiagnostic}}
prompt_templates::Vector{PromptTemplate} = PromptTemplate[]
diagnostics::Vector{PromptTemplateDiagnostic} = PromptTemplateDiagnostic[]
path_list = if paths isa String
[paths]
else
paths
end
for path in path_list
info_result = fileInfo(env, path, nothing)
if !info_result.ok
if info_result.error.code != "not_found"
push!(diagnostics, PromptTemplateDiagnostic(
"warning",
"file_info_failed",
info_result.error.message,
path,
))
end
continue
end
info = info_result.value
kind = getFileKind(env, info, diagnostics)
if kind == "directory"
result = loadTemplatesFromDir(env, info.path)
append!(prompt_templates, result.prompt_templates)
append!(diagnostics, result.diagnostics)
elseif kind == "file" && endswith(info.name, ".md")
result = loadTemplateFromFile(env, info.path)
if !isnothing(result.prompt_template)
push!(prompt_templates, result.prompt_template)
end
append!(diagnostics, result.diagnostics)
end
end
return prompt_templates, diagnostics
end
function getFileKind(env::ExecutionEnv, info::FileInfo, diagnostics::Vector{PromptTemplateDiagnostic})::Union{String, Nothing}
if info.kind == "file" || info.kind == "directory"
return info.kind
end
canonical_path = canonicalPath(env, info.path, nothing)
if !canonical_path.ok
if canonical_path.error.code != "not_found"
push!(diagnostics, PromptTemplateDiagnostic(
"warning",
"file_info_failed",
canonical_path.error.message,
info.path,
))
end
return nothing
end
target = fileInfo(env, canonical_path.value, nothing)
if !target.ok
if target.error.code != "not_found"
push!(diagnostics, PromptTemplateDiagnostic(
"warning",
"file_info_failed",
target.error.message,
info.path,
))
end
return nothing
end
if target.value.kind == "file" || target.value.kind == "directory"
return target.value.kind
end
return nothing
end
# ============================================================================
# Load templates from directory
# ============================================================================
function loadTemplatesFromDir(
env::ExecutionEnv,
dir::String,
)::Tuple{Vector{PromptTemplate}, Vector{PromptTemplateDiagnostic}}
prompt_templates::Vector{PromptTemplate} = PromptTemplate[]
diagnostics::Vector{PromptTemplateDiagnostic} = PromptTemplateDiagnostic[]
entries_result = listDir(env, dir, nothing)
if !entries_result.ok
push!(diagnostics, PromptTemplateDiagnostic(
"warning",
"list_failed",
entries_result.error.message,
dir,
))
return prompt_templates, diagnostics
end
entries = entries_result.value
for entry in sort(entries, by=e -> e.name)
kind = getFileKind(env, entry, diagnostics)
if kind != "file" || !endswith(entry.name, ".md")
continue
end
result = loadTemplateFromFile(env, entry.path)
if !isnothing(result.prompt_template)
push!(prompt_templates, result.prompt_template)
end
append!(diagnostics, result.diagnostics)
end
return prompt_templates, diagnostics
end
# ============================================================================
# Load template from file
# ============================================================================
function loadTemplateFromFile(
env::ExecutionEnv,
file_path::String,
)::Tuple{Union{PromptTemplate, Nothing}, Vector{PromptTemplateDiagnostic}}
diagnostics::Vector{PromptTemplateDiagnostic} = PromptTemplateDiagnostic[]
raw_content = readTextFile(env, file_path, nothing)
if !raw_content.ok
push!(diagnostics, PromptTemplateDiagnostic(
"warning",
"read_failed",
raw_content.error.message,
file_path,
))
return nothing, diagnostics
end
# TODO: Parse frontmatter
# parsed = parseFrontmatter<PromptTemplateFrontmatter>(rawContent.value);
# if !parsed.ok {
# diagnostics.push({
# type: "warning",
# code: "parse_failed",
# message: parsed.error.message,
# path: filePath,
# });
# return { promptTemplate: null, diagnostics };
# }
# const { frontmatter, body } = parsed.value;
# const firstLine = body.split("\n").find((line) => line.trim());
# let description = typeof frontmatter.description === "string" ? frontmatter.description : "";
# if (!description && firstLine) {
# description = firstLine.slice(0, 60);
# if (firstLine.length > 60) description += "...";
# }
# return {
# promptTemplate: {
# name: basenameEnvPath(filePath).replace(/\.md$/i, ""),
# description,
# content: body,
# },
# diagnostics,
# };
return nothing, diagnostics
end
# ============================================================================
# Parse command arguments
# ============================================================================
function parseCommandArgs(args_string::String)::Vector{String}
args::Vector{String} = String[]
current::String = ""
in_quote::Union{String, Nothing} = nothing
for i in 1:length(args_string)
char = args_string[i]
if !isnothing(in_quote)
if char == in_quote
in_quote = nothing
else
current *= char
end
elseif char == '"' || char == '\''
in_quote = char
elseif char == ' ' || char == '\t'
if !isempty(current)
push!(args, current)
current = ""
end
else
current *= char
end
end
if !isempty(current)
push!(args, current)
end
return args
end
# ============================================================================
# Substitute arguments
# ============================================================================
function substituteArgs(content::String, args::Vector{String})::String
result = content
# Substitute $1, $2, etc.
result = replace(result, r"\$(\d+)" => s -> begin
idx = parse(Int, s[1])
if idx > 0 && idx <= length(args)
return args[idx]
end
return ""
end)
# Substitute ${@:N} and ${@:N:L}
result = replace(result, r"\$\{@:(\d+)(?::(\d+))?\}" => s -> begin
m = match(r"\$\{@:(\d+)(?::(\d+))?\}", s)
if !isnothing(m)
start = parse(Int, m.captures[1]) - 1
if start < 0
start = 0
end
if !isnothing(m.captures[2])
length = parse(Int, m.captures[2])
return join(args[start+1:start+length], " ")
end
return join(args[start+1:end], " ")
end
return s
end)
# Substitute $ARGUMENTS and $@
all_args = join(args, " ")
result = replace(result, "$ARGUMENTS" => all_args)
result = replace(result, "$@" => all_args)
return result
end
# ============================================================================
# Format prompt template invocation
# ============================================================================
function formatPromptTemplateInvocation(template::PromptTemplate, args::Vector{String}=String[])::String
return substituteArgs(template.content, args)
end
# ============================================================================
# Helper functions
# ============================================================================
function basenameEnvPath(path::String)::String
normalized = rtrim(path, '/')
slash_index = findlast('/', normalized)
if isnothing(slash_index)
return normalized
end
return normalized[slash_index+1:end]
end
function findlast(pattern::Char, s::String)::Union{Int64, Nothing}
for i in length(s):-1:1
if s[i] == pattern
return i
end
end
return nothing
end
function rtrim(s::String, chars::String)::String
idx = length(s)
while idx >= 1 && s[idx] in chars
idx -= 1
end
return s[1:idx]
end
end
@@ -0,0 +1,148 @@
"""
session/jsonl_repo.jl - JSONL session repository
This module provides a JSONL-based session repository implementation.
"""
module JsonlRepo
using ..Types: *
using ..SessionStorage: SessionStorage, SessionMetadata
using ..JsonlStorage: JsonlSessionStorage, headerToSessionMetadata
using ..MemoryRepo: createSessionId, createTimestamp, getEntriesToFork, toSession
using ..HarnessTypes: SessionRepo, SessionForkOptions
# ============================================================================
# JSONL session repository
# ============================================================================
mutable struct JsonlSessionRepo <: SessionRepo{
JsonlSessionMetadata,
JsonlSessionCreateOptions,
JsonlSessionListOptions
}
fs::Any
sessions_root_input::String
sessions_root::Union{String, Nothing}
function JsonlSessionRepo(; sessions_root::String, fs::Any)
new(fs, sessions_root, nothing)
end
end
# ============================================================================
# Session repo methods
# ============================================================================
function create(repo::JsonlSessionRepo, options::JsonlSessionCreateOptions)::Session
id = if haskey(options, :id) && !isnothing(options[:id])
options[:id]
else
createSessionId()
end
created_at = createTimestamp()
session_dir = getSessionDir(repo, options.cwd)
file_path = createSessionFilePath(repo, options.cwd, id, created_at)
storage = JsonlSessionStorage(
file_path,
SessionHeader(
"session",
3,
id,
created_at,
options.cwd,
get(options, :parentSessionPath, nothing),
get(options, :metadata, nothing),
),
SessionTreeEntry[],
nothing,
)
return toSession(storage)
end
function open(repo::JsonlSessionRepo, metadata::JsonlSessionMetadata)::Session
# TODO: Open existing file
return toSession(JsonlSessionStorage(
metadata.path,
SessionHeader(
"session",
3,
metadata.id,
metadata.created_at,
metadata.cwd,
metadata.parent_session_path,
metadata.metadata,
),
SessionTreeEntry[],
nothing,
))
end
function list(repo::JsonlSessionRepo, options::JsonlSessionListOptions=JsonlSessionListOptions())::Vector{JsonlSessionMetadata}
# TODO: List sessions
return JsonlSessionMetadata[]
end
function delete(repo::JsonlSessionRepo, metadata::JsonlSessionMetadata)::Nothing
# TODO: Delete session file
return nothing
end
function fork(repo::JsonlSessionRepo, source::JsonlSessionMetadata, options::Dict{String, Any})::Session
# TODO: Fork session
return create(repo, JsonlSessionCreateOptions(
cwd=get(options, "cwd", ""),
id=get(options, "id", createSessionId()),
))
end
# ============================================================================
# Helper functions
# ============================================================================
function getSessionsRoot(repo::JsonlSessionRepo)::String
if isnothing(repo.sessions_root)
repo.sessions_root = getFileSystemResultOrThrow(
absolutePath(repo.fs, repo.sessions_root_input),
"Failed to resolve sessions root $(repo.sessions_root_input)",
)
end
return repo.sessions_root
end
function getSessionDir(repo::JsonlSessionRepo, cwd::String)::String
return getFileSystemResultOrThrow(
joinPath(repo.fs, [getSessionsRoot(repo), encodeCwd(cwd)]),
"Failed to resolve session directory for $(cwd)",
)
end
function encodeCwd(cwd::String)::String
result = replace(cwd, r"^[/\\]" => "")
result = replace(result, r"[/\\:]" => "-")
return "--$(result)--"
end
function createSessionFilePath(repo::JsonlSessionRepo, cwd::String, session_id::String, timestamp::String)::String
return getFileSystemResultOrThrow(
joinPath(repo.fs, [
getSessionDir(repo, cwd),
"$(replace(timestamp, r"[:.]" => "-"))_$(session_id).jsonl",
]),
"Failed to resolve session file path for $(session_id)",
)
end
function getFileSystemResultOrThrow(result::Result, message::String)
if !result.ok
code = result.error.code == "not_found" ? "not_found" : "storage"
throw(SessionError(code, "$(message): $(result.error.message)", result.error))
end
return result.value
end
end
@@ -0,0 +1,290 @@
"""
session/jsonl_storage.jl - JSONL session storage
This module provides JSONL-based session storage implementation.
"""
module JsonlStorage
using ..Types: *
using ..SessionStorage: SessionStorage, SessionMetadata
# ============================================================================
# Session header
# ============================================================================
mutable struct SessionHeader
type::String
version::Int64
id::String
timestamp::String
cwd::String
parent_session::Union{String, Nothing}
metadata::Union{Dict{String, Any}, Nothing}
end
# ============================================================================
# JSONL session storage
# ============================================================================
mutable struct JsonlSessionStorage{T<:SessionMetadata} <: SessionStorage{T}
file_path::String
metadata::T
entries::Vector{SessionTreeEntry}
by_id::Dict{String, SessionTreeEntry}
labels_by_id::Dict{String, String}
current_leaf_id::Union{String, Nothing}
function JsonlSessionStorage{T}(
file_path::String,
header::SessionHeader,
entries::Vector{SessionTreeEntry},
leaf_id::Union{String, Nothing},
) where T
by_id = Dict{String, SessionTreeEntry}((e.id, e) for e in entries)
labels_by_id = Dict{String, String}()
for entry in entries
if entry isa LabelEntry && !isnothing(entry.label)
labels_by_id[entry.target_id] = entry.label
end
end
new(
file_path,
header,
entries,
by_id,
labels_by_id,
leaf_id,
)
end
end
# ============================================================================
# Session storage methods
# ============================================================================
function getMetadata(storage::JsonlSessionStorage)::T
return storage.metadata
end
function getLeafId(storage::JsonlSessionStorage)::Union{String, Nothing}
if !isnothing(storage.current_leaf_id) && !haskey(storage.by_id, storage.current_leaf_id)
throw(SessionError("invalid_session", "Entry $(storage.current_leaf_id) not found"))
end
return storage.current_leaf_id
end
function setLeafId(storage::JsonlSessionStorage, leaf_id::Union{String, Nothing})::Nothing
if !isnothing(leaf_id) && !haskey(storage.by_id, leaf_id)
throw(SessionError("not_found", "Entry $(leaf_id) not found"))
end
entry = LeafEntry(
"leaf",
generateEntryId(storage.by_id),
storage.current_leaf_id,
create_timestamp(),
leaf_id,
)
# TODO: Write to file
# getFileSystemResultOrThrow(
# await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
# `Failed to append session leaf ${entry.id}`,
# );
push!(storage.entries, entry)
storage.by_id[entry.id] = entry
storage.current_leaf_id = leaf_id
return nothing
end
function createEntryId(storage::JsonlSessionStorage)::String
return generateEntryId(storage.by_id)
end
function appendEntry(storage::JsonlSessionStorage, entry::SessionTreeEntry)::Nothing
# TODO: Write to file
# getFileSystemResultOrThrow(
# await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
# `Failed to append session entry ${entry.id}`,
# );
push!(storage.entries, entry)
storage.by_id[entry.id] = entry
if entry isa LabelEntry
updateLabelCache(storage.labels_by_id, entry)
end
storage.current_leaf_id = leafIdAfterEntry(entry)
return nothing
end
function getEntry(storage::JsonlSessionStorage, id::String)::Union{SessionTreeEntry, Nothing}
return get(storage.by_id, id, nothing)
end
function findEntries(storage::JsonlSessionStorage, type::String)::Vector{SessionTreeEntry}
return filter(entry -> entry.type == type, storage.entries)
end
function getLabel(storage::JsonlSessionStorage, id::String)::Union{String, Nothing}
return get(storage.labels_by_id, id, nothing)
end
function getSessionName(storage::JsonlSessionStorage)::Union{String, Nothing}
entries = findEntries(storage, "session_info")
if isempty(entries)
return nothing
end
return strip(entries[end].name)
end
function getSessionStats(storage::JsonlSessionStorage)::SessionStats
message_count = 0
cached_tokens = 0
uncached_tokens = 0
total_tokens = 0
cost_total = 0.0
for entry in storage.entries
if entry isa MessageEntry
message_count += 1
end
usage = if entry isa MessageEntry && entry.message.role == "assistant"
entry.message.usage
elseif entry isa CompactionEntry || entry isa BranchSummaryEntry
entry.usage
else
nothing
end
if !isnothing(usage) &&
usage.input isa Int64 &&
usage.output isa Int64 &&
usage.cache_read isa Int64 &&
usage.cache_write isa Int64 &&
usage.cost.total isa Float64
cached_tokens += usage.cache_read
uncached_tokens += usage.input + usage.cache_write
total_tokens += usage.input + usage.output + usage.cache_read + usage.cache_write
cost_total += usage.cost.total
end
end
return SessionStats(
message_count,
cached_tokens,
uncached_tokens,
total_tokens,
cost_total,
)
end
function getPathToRootOrCompaction(storage::JsonlSessionStorage, leaf_id::Union{String, Nothing})::Vector{SessionTreeEntry}
if isnothing(leaf_id)
return SessionTreeEntry[]
end
path::Vector{SessionTreeEntry} = SessionTreeEntry[]
stop_at_entry_id::Union{String, Nothing} = nothing
current = get(storage.by_id, leaf_id, nothing)
if isnothing(current)
throw(SessionError("not_found", "Entry $(leaf_id) not found"))
end
while !isnothing(current)
unshift!(path, current)
if !isnothing(stop_at_entry_id) && current.id == stop_at_entry_id
break
end
if current isa CompactionEntry
if !isnothing(current.retained_tail)
break
end
stop_at_entry_id = current.first_kept_entry_id
end
if isnothing(current.parent_id)
break
end
parent = get(storage.by_id, current.parent_id, nothing)
if isnothing(parent)
throw(SessionError("invalid_session", "Entry $(current.parent_id) not found"))
end
current = parent
end
return path
end
function getEntries(storage::JsonlSessionStorage, options::Dict{String, Any})::Vector{SessionTreeEntry}
start = get(options, "afterEntrySeq", 0)
end_idx = if haskey(options, "limit")
start + options["limit"]
else
nothing
end
if isnothing(end_idx)
return copy(storage.entries[start+1:end])
end
return copy(storage.entries[start+1:end_idx])
end
# ============================================================================
# Helper functions
# ============================================================================
function updateLabelCache(labels_by_id::Dict{String, String}, entry::SessionTreeEntry)::Nothing
if entry isa LabelEntry
label = strip(get(entry, :label, nothing))
if !isnothing(label) && !isempty(label)
labels_by_id[entry.target_id] = label
else
delete!(labels_by_id, entry.target_id)
end
end
return nothing
end
function generateEntryId(by_id::Dict{String, SessionTreeEntry})::String
for i in 1:100
id = uuidv7()[end-7:end]
if !haskey(by_id, id)
return id
end
end
return uuidv7()
end
function leafIdAfterEntry(entry::SessionTreeEntry)::Union{String, Nothing}
if entry isa LeafEntry
return entry.target_id
end
return entry.id
end
function headerToSessionMetadata(header::SessionHeader, path::String)::JsonlSessionMetadata
return JsonlSessionMetadata(
header.id,
header.timestamp,
header.cwd,
path,
header.parent_session,
header.metadata,
)
end
end
@@ -0,0 +1,133 @@
"""
session/memory_repo.jl - In-memory session repository
This module provides an in-memory session repository implementation for testing.
"""
module MemoryRepo
using ..Types: *
using ..SessionStorage: SessionStorage, SessionMetadata
using ..MemoryStorage: InMemorySessionStorage
# ============================================================================
# In-memory session repository
# ============================================================================
mutable struct InMemorySessionRepo <: SessionRepo{SessionMetadata, Dict{String, Any}, Nothing}
sessions::Dict{String, Session}
function InMemorySessionRepo()
new(Dict{String, Session}())
end
end
# ============================================================================
# Session repo methods
# ============================================================================
function create(repo::InMemorySessionRepo, options::Dict{String, Any}=Dict{String, Any}())::Session
metadata = SessionMetadata(
if haskey(options, :id) && !isnothing(options[:id])
options[:id]
else
createSessionId()
end,
createTimestamp(),
)
storage = InMemorySessionStorage{SessionMetadata}(metadata=metadata)
session = toSession(storage)
repo.sessions[metadata.id] = session
return session
end
function open(repo::InMemorySessionRepo, metadata::SessionMetadata)::Session
session = get(repo.sessions, metadata.id, nothing)
if isnothing(session)
throw(SessionError("not_found", "Session not found: $(metadata.id)"))
end
return session
end
function list(repo::InMemorySessionRepo)::Vector{SessionMetadata}
return [getMetadata(session) for session in values(repo.sessions)]
end
function delete(repo::InMemorySessionRepo, metadata::SessionMetadata)::Nothing
delete!(repo.sessions, metadata.id)
return nothing
end
function fork(repo::InMemorySessionRepo, source::SessionMetadata, options::Dict{String, Any})::Session
source_session = open(repo, source)
forked_entries = getEntriesToFork(getStorage(source_session), options)
metadata = SessionMetadata(
if haskey(options, :id) && !isnothing(options[:id])
options[:id]
else
createSessionId()
end,
createTimestamp(),
)
storage = InMemorySessionStorage{SessionMetadata}(
entries=forked_entries,
metadata=metadata,
)
session = toSession(storage)
repo.sessions[metadata.id] = session
return session
end
# ============================================================================
# Helper functions
# ============================================================================
function createSessionId()::String
return uuidv7()
end
function createTimestamp()::String
return create_timestamp()
end
function toSession(storage::SessionStorage)::Session
return Session(storage)
end
function getEntriesToFork(storage::SessionStorage, options::Dict{String, Any})::Vector{SessionTreeEntry}
if !haskey(options, :entryId) || isnothing(options[:entryId])
return getEntries(storage, Dict{String, Any}())
end
target = getEntry(storage, options[:entryId])
if isnothing(target)
throw(SessionError("invalid_fork_target", "Entry $(options[:entryId]) not found"))
end
effective_leaf_id::Union{String, Nothing}
position = get(options, "position", "before")
if position == "at"
effective_leaf_id = target.id
else
if target isa MessageEntry && target.message.role != "user"
throw(SessionError("invalid_fork_target", "Entry $(options[:entryId]) is not a user message"))
end
effective_leaf_id = target.parent_id
end
return getPathToRootOrCompaction(storage, effective_leaf_id)
end
function getStorage(session::Session)::SessionStorage
return session.storage
end
end
@@ -0,0 +1,227 @@
"""
session/memory_storage.jl - In-memory session storage
This module provides an in-memory session storage implementation for testing and temporary use.
"""
module MemoryStorage
using ..Types: *
using ..SessionStorage: SessionStorage, SessionMetadata
using ..JsonlStorage: updateLabelCache, generateEntryId, leafIdAfterEntry
# ============================================================================
# In-memory session storage
# ============================================================================
mutable struct InMemorySessionStorage{T<:SessionMetadata} <: SessionStorage{T}
metadata::T
entries::Vector{SessionTreeEntry}
by_id::Dict{String, SessionTreeEntry}
labels_by_id::Dict{String, String}
leaf_id::Union{String, Nothing}
function InMemorySessionStorage{T}(;
entries::Vector{SessionTreeEntry}=SessionTreeEntry[],
metadata::Union{T, Nothing]=nothing,
) where T
by_id = Dict{String, SessionTreeEntry}((e.id, e) for e in entries)
labels_by_id = Dict{String, String}()
leaf_id = nothing
for entry in entries
if entry isa LabelEntry
updateLabelCache(labels_by_id, entry)
end
leaf_id = leafIdAfterEntry(entry)
end
if !isnothing(leaf_id) && !haskey(by_id, leaf_id)
throw(SessionError("invalid_session", "Entry $(leaf_id) not found"))
end
new(
if isnothing(metadata)
T(uuidv7(), create_timestamp())
else
metadata
end,
copy(entries),
by_id,
labels_by_id,
leaf_id,
)
end
end
# ============================================================================
# Session storage methods
# ============================================================================
function getMetadata(storage::InMemorySessionStorage)::T
return storage.metadata
end
function getLeafId(storage::InMemorySessionStorage)::Union{String, Nothing}
if !isnothing(storage.leaf_id) && !haskey(storage.by_id, storage.leaf_id)
throw(SessionError("invalid_session", "Entry $(storage.leaf_id) not found"))
end
return storage.leaf_id
end
function setLeafId(storage::InMemorySessionStorage, leaf_id::Union{String, Nothing})::Nothing
if !isnothing(leaf_id) && !haskey(storage.by_id, leaf_id)
throw(SessionError("not_found", "Entry $(leaf_id) not found"))
end
entry = LeafEntry(
"leaf",
generateEntryId(storage.by_id),
storage.leaf_id,
create_timestamp(),
leaf_id,
)
push!(storage.entries, entry)
storage.by_id[entry.id] = entry
storage.leaf_id = leaf_id
return nothing
end
function createEntryId(storage::InMemorySessionStorage)::String
return generateEntryId(storage.by_id)
end
function appendEntry(storage::InMemorySessionStorage, entry::SessionTreeEntry)::Nothing
push!(storage.entries, entry)
storage.by_id[entry.id] = entry
if entry isa LabelEntry
updateLabelCache(storage.labels_by_id, entry)
end
storage.leaf_id = leafIdAfterEntry(entry)
return nothing
end
function getEntry(storage::InMemorySessionStorage, id::String)::Union{SessionTreeEntry, Nothing}
return get(storage.by_id, id, nothing)
end
function findEntries(storage::InMemorySessionStorage, type::String)::Vector{SessionTreeEntry}
return filter(entry -> entry.type == type, storage.entries)
end
function getLabel(storage::InMemorySessionStorage, id::String)::Union{String, Nothing}
return get(storage.labels_by_id, id, nothing)
end
function getSessionName(storage::InMemorySessionStorage)::Union{String, Nothing}
entries = findEntries(storage, "session_info")
if isempty(entries)
return nothing
end
return strip(entries[end].name)
end
function getSessionStats(storage::InMemorySessionStorage)::SessionStats
message_count = 0
cached_tokens = 0
uncached_tokens = 0
total_tokens = 0
cost_total = 0.0
for entry in storage.entries
if entry isa MessageEntry
message_count += 1
end
usage = if entry isa MessageEntry && entry.message.role == "assistant"
entry.message.usage
elseif entry isa CompactionEntry || entry isa BranchSummaryEntry
entry.usage
else
nothing
end
if !isnothing(usage) &&
usage.input isa Int64 &&
usage.output isa Int64 &&
usage.cache_read isa Int64 &&
usage.cache_write isa Int64 &&
usage.cost.total isa Float64
cached_tokens += usage.cache_read
uncached_tokens += usage.input + usage.cache_write
total_tokens += usage.input + usage.output + usage.cache_read + usage.cache_write
cost_total += usage.cost.total
end
end
return SessionStats(
message_count,
cached_tokens,
uncached_tokens,
total_tokens,
cost_total,
)
end
function getPathToRootOrCompaction(storage::InMemorySessionStorage, leaf_id::Union{String, Nothing})::Vector{SessionTreeEntry}
if isnothing(leaf_id)
return SessionTreeEntry[]
end
path::Vector{SessionTreeEntry} = SessionTreeEntry[]
stop_at_entry_id::Union{String, Nothing} = nothing
current = get(storage.by_id, leaf_id, nothing)
if isnothing(current)
throw(SessionError("not_found", "Entry $(leaf_id) not found"))
end
while !isnothing(current)
unshift!(path, current)
if !isnothing(stop_at_entry_id) && current.id == stop_at_entry_id
break
end
if current isa CompactionEntry
if !isnothing(current.retained_tail)
break
end
stop_at_entry_id = current.first_kept_entry_id
end
if isnothing(current.parent_id)
break
end
parent = get(storage.by_id, current.parent_id, nothing)
if isnothing(parent)
throw(SessionError("invalid_session", "Entry $(current.parent_id) not found"))
end
current = parent
end
return path
end
function getEntries(storage::InMemorySessionStorage, options::Dict{String, Any})::Vector{SessionTreeEntry}
start = get(options, "afterEntrySeq", 0)
end_idx = if haskey(options, "limit")
start + options["limit"]
else
nothing
end
if isnothing(end_idx)
return copy(storage.entries[start+1:end])
end
return copy(storage.entries[start+1:end_idx])
end
end
@@ -0,0 +1,65 @@
"""
session/repo_utils.jl - Session repository utilities
This module provides shared utilities for session repository implementations.
"""
module RepoUtils
using ..Types: *
using ..SessionStorage: SessionStorage, SessionMetadata
using ..Session: Session
# ============================================================================
# Helper functions
# ============================================================================
function createSessionId()::String
return uuidv7()
end
function createTimestamp()::String
return create_timestamp()
end
function toSession{T<:SessionMetadata}(storage::SessionStorage{T})::Session{T}
return Session(storage)
end
function getFileSystemResultOrThrow{TValue}(result::Result{TValue, FileError}, message::String)::TValue
if !result.ok
code = result.error.code == "not_found" ? "not_found" : "storage"
throw(SessionError(code, "$(message): $(result.error.message)", result.error))
end
return result.value
end
function getEntriesToFork(
storage::SessionStorage,
options::Dict{String, Any},
)::Vector{SessionTreeEntry}
if !haskey(options, :entryId) || isnothing(options[:entryId])
return getEntries(storage, Dict{String, Any}())
end
target = getEntry(storage, options[:entryId])
if isnothing(target)
throw(SessionError("invalid_fork_target", "Entry $(options[:entryId]) not found"))
end
effective_leaf_id::Union{String, Nothing}
position = get(options, "position", "before")
if position == "at"
effective_leaf_id = target.id
else
if target isa MessageEntry && target.message.role != "user"
throw(SessionError("invalid_fork_target", "Entry $(options[:entryId]) is not a user message"))
end
effective_leaf_id = target.parent_id
end
return getPathToRootOrCompaction(storage, effective_leaf_id)
end
end
@@ -0,0 +1,422 @@
"""
session/session.jl - Session management
This module provides the Session class for managing conversation history with branch support.
"""
module Session
using ..Types: *
using ..SessionStorage: SessionStorage
using ..Messages: *
using ..HarnessTypes: *
# ============================================================================
# Session context build options
# ============================================================================
mutable struct SessionContextBuildOptions
entry_transforms::Union{Vector{Function}, Nothing}
entry_projectors::Union{Dict{String, Function}, Nothing}
end
# ============================================================================
# Default context entry transform
# ============================================================================
function defaultContextEntryTransform(path_entries::Vector{SessionTreeEntry})::Vector{SessionTreeEntry}
compaction = nothing
for entry in path_entries
if entry isa CompactionEntry
compaction = entry
break
end
end
if isnothing(compaction)
return copy(path_entries)
end
entries::Vector{SessionTreeEntry} = [compaction]
compaction_idx = findfirst(
(entry) -> entry isa CompactionEntry && entry.id == compaction.id,
path_entries,
)
if !isnothing(compaction.retained_tail)
for i in compaction_idx+1:length(path_entries)
push!(entries, path_entries[i])
end
return entries
end
if !isnothing(compaction.first_kept_entry_id)
found_first_kept = false
for i in 1:compaction_idx-1
entry = path_entries[i]
if entry.id == compaction.first_kept_entry_id
found_first_kept = true
end
if found_first_kept
push!(entries, entry)
end
end
end
for i in compaction_idx+1:length(path_entries)
push!(entries, path_entries[i])
end
return entries
end
# ============================================================================
# Build context entries
# ============================================================================
function buildContextEntries(
path_entries::Vector{SessionTreeEntry},
options::SessionContextBuildOptions=SessionContextBuildOptions(nothing, nothing),
)::Vector{SessionTreeEntry}
entries = defaultContextEntryTransform(path_entries)
if !isnothing(options.entry_transforms)
for transform in options.entry_transforms
entries = transform(entries)
end
end
return entries
end
# ============================================================================
# Session entry to context messages
# ============================================================================
function sessionEntryToContextMessages(
entry::SessionTreeEntry,
index::Int64,
entries::Vector{SessionTreeEntry},
options::SessionContextBuildOptions=SessionContextBuildOptions(nothing, nothing),
)::Vector{AgentMessage}
if entry isa MessageEntry
return [entry.message]
end
if entry isa CustomMessageEntry
return [createCustomMessage(
entry.custom_type,
entry.content,
entry.display,
entry.details,
entry.timestamp,
)]
end
if entry isa CompactionEntry
messages = [createCompactionSummaryMessage(
entry.summary,
entry.tokens_before,
entry.timestamp,
)]
if !isnothing(entry.retained_tail)
append!(messages, entry.retained_tail)
end
return messages
end
if entry isa BranchSummaryEntry
return [createBranchSummaryMessage(
entry.summary,
entry.from_id,
entry.timestamp,
)]
end
if entry isa CustomEntry
if !isnothing(options.entry_projectors) && haskey(options.entry_projectors, entry.custom_type)
projector = options.entry_projectors[entry.custom_type]
return projector(entry, index, entries)
end
return AgentMessage[]
end
return AgentMessage[]
end
# ============================================================================
# Build session context
# ============================================================================
function buildSessionContext(
path_entries::Vector{SessionTreeEntry},
options::SessionContextBuildOptions=SessionContextBuildOptions(nothing, nothing),
)::SessionContext
state = deriveSessionContextState(path_entries)
context_entries = buildContextEntries(path_entries, options)
messages = SessionTreeEntry[]
for (i, entry) in enumerate(context_entries)
append!(messages, sessionEntryToContextMessages(entry, i, context_entries, options))
end
return SessionContext(messages, state.thinking_level, state.model, state.active_tool_names)
end
function deriveSessionContextState(path_entries::Vector{SessionTreeEntry})::Dict{String, Any}
thinking_level = "off"
model = nothing
active_tool_names = nothing
for entry in path_entries
if entry isa ThinkingLevelChangeEntry
thinking_level = entry.thinking_level
elseif entry isa ModelChangeEntry
model = Dict{String, String}("provider" => entry.provider, "modelId" => entry.model_id)
elseif entry isa MessageEntry && entry.message.role == "assistant"
model = Dict{String, String}("provider" => entry.message.provider, "modelId" => entry.message.model)
elseif entry isa ActiveToolsChangeEntry
active_tool_names = copy(entry.active_tool_names)
end
end
return Dict{String, Any}(
"thinking_level" => thinking_level,
"model" => model,
"active_tool_names" => active_tool_names,
)
end
# ============================================================================
# Session class
# ============================================================================
mutable struct Session{T<:SessionMetadata}
storage::SessionStorage{T}
context_build_options::SessionContextBuildOptions
function Session(
storage::SessionStorage,
context_build_options::SessionContextBuildOptions=SessionContextBuildOptions(nothing, nothing),
)
new{typeof(storage.metadata)}(storage, context_build_options)
end
end
# ============================================================================
# Session methods
# ============================================================================
function getMetadata(session::Session)::T
return getMetadata(session.storage)
end
function getStorage(session::Session)::SessionStorage
return session.storage
end
function getLeafId(session::Session)::Union{String, Nothing}
return getLeafId(session.storage)
end
function getEntry(session::Session, id::String)::Union{SessionTreeEntry, Nothing}
return getEntry(session.storage, id)
end
function getEntries(session::Session, options::Dict{String, Any}=Dict{String, Any}())::Vector{SessionTreeEntry}
return getEntries(session.storage, options)
end
function getBranch(session::Session, from_id::Union{String, Nothing}=nothing)::Vector{SessionTreeEntry}
leaf_id = if isnothing(from_id)
getLeafId(session.storage)
else
from_id
end
return getPathToRootOrCompaction(session.storage, leaf_id)
end
function buildContextEntries(session::Session, options::SessionContextBuildOptions=SessionContextBuildOptions())::Vector{SessionTreeEntry}
return buildContextEntries(getBranch(session), mergeContextBuildOptions(session, options))
end
function buildContext(session::Session, options::SessionContextBuildOptions=SessionContextBuildOptions())::SessionContext
return buildSessionContext(getBranch(session), mergeContextBuildOptions(session, options))
end
function mergeContextBuildOptions(session::Session, options::SessionContextBuildOptions)::SessionContextBuildOptions
return SessionContextBuildOptions(
vcat(
isnothing(session.context_build_options.entry_transforms) ? [] : session.context_build_options.entry_transforms,
isnothing(options.entry_transforms) ? [] : options.entry_transforms,
),
merge(
isnothing(session.context_build_options.entry_projectors) ? Dict{String, Any}() : session.context_build_options.entry_projectors,
isnothing(options.entry_projectors) ? Dict{String, Any}() : options.entry_projectors,
promote=true,
),
)
end
function getLabel(session::Session, id::String)::Union{String, Nothing}
return getLabel(session.storage, id)
end
function getSessionStats(session::Session)::SessionStats
return getSessionStats(session.storage)
end
function getSessionName(session::Session)::Union{String, Nothing}
return getSessionName(session.storage)
end
function appendMessage(session::Session, message::AgentMessage)::String
return appendTypedEntry(session, MessageEntry(
"message",
createEntryId(session.storage),
getLeafId(session.storage),
create_timestamp(),
message,
))
end
function appendThinkingLevelChange(session::Session, thinking_level::String)::String
return appendTypedEntry(session, ThinkingLevelChangeEntry(
"thinking_level_change",
createEntryId(session.storage),
getLeafId(session.storage),
create_timestamp(),
thinking_level,
))
end
function appendModelChange(session::Session, provider::String, model_id::String)::String
return appendTypedEntry(session, ModelChangeEntry(
"model_change",
createEntryId(session.storage),
getLeafId(session.storage),
create_timestamp(),
provider,
model_id,
))
end
function appendActiveToolsChange(session::Session, active_tool_names::Vector{String})::String
return appendTypedEntry(session, ActiveToolsChangeEntry(
"active_tools_change",
createEntryId(session.storage),
getLeafId(session.storage),
create_timestamp(),
active_tool_names,
))
end
function appendCompaction(
session::Session,
summary::String,
first_kept_entry_id::Union{String, Nothing},
tokens_before::Int64,
details::Union{Any, Nothing}=nothing,
from_hook::Bool=false,
usage::Union{Usage, Nothing}=nothing,
retained_tail::Union{Vector{AgentMessage}, Nothing}=nothing,
)::String
return appendTypedEntry(session, CompactionEntry(
"compaction",
createEntryId(session.storage),
getLeafId(session.storage),
create_timestamp(),
summary,
first_kept_entry_id,
tokens_before,
retained_tail,
details,
usage,
from_hook,
))
end
function appendCustomEntry(session::Session, custom_type::String, data::Union{Any, Nothing}=nothing)::String
return appendTypedEntry(session, CustomEntry(
"custom",
createEntryId(session.storage),
getLeafId(session.storage),
create_timestamp(),
custom_type,
data,
))
end
function appendCustomMessageEntry(
session::Session,
custom_type::String,
content::String,
display::Bool,
details::Union{Any, Nothing}=nothing,
)::String
return appendTypedEntry(session, CustomMessageEntry(
"custom_message",
createEntryId(session.storage),
getLeafId(session.storage),
create_timestamp(),
custom_type,
content,
details,
display,
))
end
function appendLabel(session::Session, target_id::String, label::Union{String, Nothing})::String
if isnothing(getEntry(session, target_id))
throw(SessionError("not_found", "Entry $(target_id) not found"))
end
return appendTypedEntry(session, LabelEntry(
"label",
createEntryId(session.storage),
getLeafId(session.storage),
create_timestamp(),
target_id,
label,
))
end
function appendSessionName(session::Session, name::String)::String
sanitizedName = replace(name, r"[\r\n]+" => " ")
return appendTypedEntry(session, SessionInfoEntry(
"session_info",
createEntryId(session.storage),
getLeafId(session.storage),
create_timestamp(),
sanitizedName,
))
end
function moveTo(
session::Session,
entry_id::Union{String, Nothing},
summary::Union{Dict{String, Any}, Nothing}=nothing,
)::Union{String, Nothing
if !isnothing(entry_id) && isnothing(getEntry(session, entry_id))
throw(SessionError("not_found", "Entry $(entry_id) not found"))
end
setLeafId(session.storage, entry_id)
if isnothing(summary)
return nothing
end
return appendTypedEntry(session, BranchSummaryEntry(
"branch_summary",
createEntryId(session.storage),
entry_id,
create_timestamp(),
entry_id,
summary["summary"],
get(summary, "details", nothing),
get(summary, "usage", nothing),
get(summary, "from_hook", false),
))
end
function appendTypedEntry(session::Session, entry::SessionTreeEntry)::String
appendEntry(session.storage, entry)
return entry.id
end
end
@@ -0,0 +1,375 @@
"""
skills.jl - Skill loading and formatting
This module provides utilities for loading skills from SKILL.md files and formatting skill invocations.
"""
module Skills
using ..Types: *
using ..HarnessTypes: Skill, ExecutionEnv, FileSystem, toError, FileError, Result, ok, err
const MAX_NAME_LENGTH = 64
const MAX_DESCRIPTION_LENGTH = 1024
const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"]
# ============================================================================
# Skill diagnostic types
# ============================================================================
const SkillDiagnosticCode = String
const SKILL_DIAGNOSTIC_FILE_INFO_FAILED = "file_info_failed"
const SKILL_DIAGNOSTIC_LIST_FAILED = "list_failed"
const SKILL_DIAGNOSTIC_READ_FAILED = "read_failed"
const SKILL_DIAGNOSTIC_PARSE_FAILED = "parse_failed"
const SKILL_DIAGNOSTIC_INVALID_METADATA = "invalid_metadata"
mutable struct SkillDiagnostic
type::String
code::SkillDiagnosticCode
message::String
path::String
end
# ============================================================================
# Skill frontmatter
# ============================================================================
mutable struct SkillFrontmatter
name::Union{String, Nothing}
description::Union{String, Nothing}
disable_model_invocation::Union{Bool, Nothing}
extra::Dict{String, Any}
end
# ============================================================================
# Format skill invocation
# ============================================================================
function formatSkillInvocation(skill::Skill, additional_instructions::Union{String, Nothing})::String
skill_block = "<skill name=\"$(skill.name)\" location=\"$(skill.filePath)\">\nReferences are relative to $(dirnameEnvPath(skill.filePath)).\n\n$(skill.content)\n</skill>"
if isnothing(additional_instructions)
return skill_block
end
return "$(skill_block)\n\n$(additional_instructions)"
end
# ============================================================================
# Load skills from directories
# ============================================================================
function loadSkills(env::ExecutionEnv, dirs::Union{String, Vector{String}})::Tuple{Vector{Skill}, Vector{SkillDiagnostic}}
skills::Vector{Skill} = Skill[]
diagnostics::Vector{SkillDiagnostic} = SkillDiagnostic[]
dir_list = if dirs isa String
[dirs]
else
dirs
end
for dir in dir_list
root_info_result = fileInfo(env, dir, nothing)
if !root_info_result.ok
if root_info_result.error.code != "not_found"
push!(diagnostics, SkillDiagnostic(
"warning",
"file_info_failed",
root_info_result.error.message,
dir,
))
end
continue
end
root_info = root_info_result.value
if !isDirectory(env, root_info, diagnostics)
continue
end
result = loadSkillsFromDirInternal(env, root_info.path, true, Dict{String, Any}(), root_info.path)
append!(skills, result.skills)
append!(diagnostics, result.diagnostics)
end
return skills, diagnostics
end
function isDirectory(env::ExecutionEnv, info::FileInfo, diagnostics::Vector{SkillDiagnostic})::Bool
return info.kind == "directory"
end
function loadSkillsFromDirInternal(
env::ExecutionEnv,
dir::String,
include_root_files::Bool,
ignore_matcher::Dict{String, Any},
root_dir::String,
)::Tuple{Vector{Skill}, Vector{SkillDiagnostic}}
skills::Vector{Skill} = Skill[]
diagnostics::Vector{SkillDiagnostic} = SkillDiagnostic[]
dir_info_result = fileInfo(env, dir, nothing)
if !dir_info_result.ok
if dir_info_result.error.code != "not_found"
push!(diagnostics, SkillDiagnostic(
"warning",
"file_info_failed",
dir_info_result.error.message,
dir,
))
end
return skills, diagnostics
end
dir_info = dir_info_result.value
if !isDirectory(env, dir_info, diagnostics)
return skills, diagnostics
end
# TODO: Implement ignore rules
# await addIgnoreRules(env, ignoreMatcher, dir, rootDir, diagnostics);
entries_result = listDir(env, dir, nothing)
if !entries_result.ok
push!(diagnostics, SkillDiagnostic(
"warning",
"list_failed",
entries_result.error.message,
dir,
))
return skills, diagnostics
end
entries = entries_result.value
# Look for SKILL.md
for entry in entries
if entry.name != "SKILL.md"
continue
end
full_path = entry.path
if !isFile(env, entry, diagnostics)
continue
end
result = loadSkillFromFile(env, full_path)
if !isnothing(result.skill)
push!(skills, result.skill)
end
append!(diagnostics, result.diagnostics)
return skills, diagnostics
end
# Process other files
for entry in sort(entries, by=e -> e.name)
if startswith(entry.name, ".") || entry.name == "node_modules"
continue
end
full_path = entry.path
kind = getFileKind(env, entry, diagnostics)
if isnothing(kind)
continue
end
rel_path = relativeEnvPath(root_dir, full_path)
ignore_path = kind == "directory" ? "$(rel_path)/" : rel_path
if !isnothing(ignore_matcher) && haskey(ignore_matcher, ignore_path)
continue
end
if kind == "directory"
result = loadSkillsFromDirInternal(env, full_path, false, ignore_matcher, root_dir)
append!(skills, result.skills)
append!(diagnostics, result.diagnostics)
continue
end
if kind != "file" || !include_root_files || !endswith(entry.name, ".md")
continue
end
result = loadSkillFromFile(env, full_path)
if !isnothing(result.skill)
push!(skills, result.skill)
end
append!(diagnostics, result.diagnostics)
end
return skills, diagnostics
end
function isFile(env::ExecutionEnv, info::FileInfo, diagnostics::Vector{SkillDiagnostic})::Bool
return info.kind == "file"
end
function getFileKind(env::ExecutionEnv, info::FileInfo, diagnostics::Vector{SkillDiagnostic})::Union{String, Nothing}
if info.kind == "file" || info.kind == "directory"
return info.kind
end
canonical_path = canonicalPath(env, info.path, nothing)
if !canonical_path.ok
if canonical_path.error.code != "not_found"
push!(diagnostics, SkillDiagnostic(
"warning",
"file_info_failed",
canonical_path.error.message,
info.path,
))
end
return nothing
end
target = fileInfo(env, canonical_path.value, nothing)
if !target.ok
if target.error.code != "not_found"
push!(diagnostics, SkillDiagnostic(
"warning",
"file_info_failed",
target.error.message,
info.path,
))
end
return nothing
end
if target.value.kind == "file" || target.value.kind == "directory"
return target.value.kind
end
return nothing
end
# ============================================================================
# Load skill from file
# ============================================================================
function loadSkillFromFile(env::ExecutionEnv, file_path::String)::Tuple{Union{Skill, Nothing}, Vector{SkillDiagnostic}}
diagnostics::Vector{SkillDiagnostic} = SkillDiagnostic[]
raw_content = readTextFile(env, file_path, nothing)
if !raw_content.ok
push!(diagnostics, SkillDiagnostic(
"warning",
"read_failed",
raw_content.error.message,
file_path,
))
return nothing, diagnostics
end
# TODO: Parse frontmatter
# parsed = parseFrontmatter<SkillFrontmatter>(rawContent.value);
# if !parsed.ok {
# diagnostics.push({ type: "warning", code: "parse_failed", message: parsed.error.message, path: filePath });
# return { skill: null, diagnostics };
# }
# const { frontmatter, body } = parsed.value;
# const skillDir = dirnameEnvPath(filePath);
# const parentDirName = basenameEnvPath(skillDir);
# const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined;
# for (const error of validateDescription(description)) {
# diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath });
# }
# const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name : undefined;
# const name = frontmatterName || parentDirName;
# for (const error of validateName(name, parentDirName)) {
# diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath });
# }
# if (!description || description.trim() === "") {
# return { skill: null, diagnostics };
# }
# return {
# skill: {
# name,
# description,
# content: body,
# filePath,
# disableModelInvocation: frontmatter["disable-model-invocation"] === true,
# },
# diagnostics,
# };
return nothing, diagnostics
end
# ============================================================================
# Path utility functions
# ============================================================================
function joinEnvPath(base::String, child::String)::String
return "$(rtrim(base, '/'))/$(ltrim(child, '/'))"
end
function dirnameEnvPath(path::String)::String
normalized = rtrim(path, '/')
slash_index = findlast('/', normalized)
if isnothing(slash_index) || slash_index <= 1
return "/"
end
return normalized[1:slash_index-1]
end
function basenameEnvPath(path::String)::String
normalized = rtrim(path, '/')
slash_index = findlast('/', normalized)
if isnothing(slash_index)
return normalized
end
return normalized[slash_index+1:end]
end
function relativeEnvPath(root::String, path::String)::String
normalized_root = rtrim(root, '/')
normalized_path = rtrim(path, '/')
if normalized_path == normalized_root
return ""
end
if startswith(normalized_path, "$(normalized_root)/")
return normalized_path[length(normalized_root)+2:end]
end
return lstrip(normalized_path, '/')
end
# ============================================================================
# Helper functions
# ============================================================================
function lstrip(s::String, chars::String)::String
idx = 1
while idx <= length(s) && s[idx] in chars
idx += 1
end
return s[idx:end]
end
function rtrim(s::String, chars::String)::String
idx = length(s)
while idx >= 1 && s[idx] in chars
idx -= 1
end
return s[1:idx]
end
function findlast(pattern::Char, s::String)::Union{Int64, Nothing}
for i in length(s):-1:1
if s[i] == pattern
return i
end
end
return nothing
end
end
@@ -0,0 +1,45 @@
"""
stream_fn.jl - Stream function utilities
This module provides the default stream function configuration for AgentCore.
"""
module StreamFn
using ..Types: StreamFn
let default_stream_fn::Union{StreamFn, Nothing} = nothing
"""
setDefaultStreamFn(stream_fn)
Configure the fallback used by Agent and low-level loops when callers omit stream_fn.
# Arguments
- `stream_fn`: The stream function to set as default
"""
function setDefaultStreamFn(stream_fn::Union{StreamFn, Nothing})
global default_stream_fn = stream_fn
end
"""
getDefaultStreamFn()
Get the configured default stream function, or throw an error if none is configured.
# Returns
- The configured stream function
# Throws
- ErrorException if no default stream function is configured
"""
function getDefaultStreamFn()::StreamFn
if isnothing(default_stream_fn)
throw(ErrorException(
"No default stream function configured. Pass stream_fn explicitly or call setDefaultStreamFn()."
))
end
return default_stream_fn
end
end
@@ -0,0 +1,56 @@
"""
system_prompt.jl - System prompt formatting
This module provides utilities for formatting skills in the system prompt.
"""
module SystemPrompt
using ..Types: Skill
"""
formatSkillsForSystemPrompt(skills)
Format skills for inclusion in the system prompt using XML-formatted blocks.
"""
function formatSkillsForSystemPrompt(skills::Vector{Skill})::String
visible_skills = filter(s -> !s.disableModelInvocation, skills)
if isempty(visible_skills)
return ""
end
lines = String[
"The following skills provide specialized instructions for specific tasks.",
"Read the full skill file when the task matches its description.",
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
"",
"<available_skills>",
]
for skill in visible_skills
push!(lines, " <skill>")
push!(lines, " <name>$(escapeXml(skill.name))</name>")
push!(lines, " <description>$(escapeXml(skill.description))</description>")
push!(lines, " <location>$(escapeXml(skill.filePath))</location>")
push!(lines, " </skill>")
end
push!(lines, "</available_skills>")
return join(lines, "\n")
end
"""
escapeXml(value)
Escape special characters in a string for XML.
"""
function escapeXml(value::String)::String
result = replace(value, "&" => "&amp;")
result = replace(result, "<" => "&lt;")
result = replace(result, ">" => "&gt;")
result = replace(result, "\"" => "&quot;")
result = replace(result, "'" => "&apos;")
return result
end
end
@@ -0,0 +1,49 @@
"""
tools/bash.jl - Bash execution tool
This module provides the bash execution tool for AgentCore.
"""
module Bash
using ..Types: *
struct BashExecution
command::String
cwd::String
env::Dict{String, String}
inherit_env::Bool
end
mutable struct BashPrepare{TContext}
function::Function
context::TContext
signal::Union{Any, Nothing}
end
mutable struct BashToolOptions{TContext}
command_prefix::Union{String, Nothing}
prepare::Union{BashPrepare{TContext}, Nothing}
end
mutable struct BashToolDetails
truncation::Union{Any, Nothing}
full_output_path::Union{String, Nothing}
end
function createBashTool{TContext}(options::Union{BashToolOptions{TContext}, Nothing}=nothing) where TContext
return AgentTool(
"bash",
"bash",
"Execute a bash command in the current working directory.",
Dict{String, Any}(),
(tool_call_id, params, signal, on_update, context) -> begin
# TODO: Implement bash execution
return AgentToolResult([TextContent("Command executed successfully")], nothing, nothing, nothing, nothing)
end,
nothing,
nothing,
)
end
end
@@ -0,0 +1,32 @@
"""
tools/edit.jl - File edit tool
This module provides the file edit tool for AgentCore.
"""
module Edit
using ..Types: *
mutable struct EditToolDetails
diff::String
patch::String
first_changed_line::Union{Int64, Nothing}
end
function createEditTool{TContext}() where TContext
return AgentTool(
"edit",
"edit",
"Edit a single file using exact text replacement.",
Dict{String, Any}(),
(tool_call_id, params, signal, on_update, context) -> begin
# TODO: Implement edit execution
return AgentToolResult([TextContent("File edited successfully")], nothing, nothing, nothing, nothing)
end,
nothing,
nothing,
)
end
end
@@ -0,0 +1,67 @@
"""
tools/edit_diff.jl - Edit diff utilities
This module provides shared diff computation utilities for the edit tool.
"""
module EditDiff
using ..Types: *
function detectLineEnding(content::String)::String
crlf_idx = findfirst("\r\n", content)
lf_idx = findfirst("\n", content)
if isnothing(lf_idx)
return "\n"
end
if isnothing(crlf_idx)
return "\n"
end
return crlf_idx < lf_idx ? "\r\n" : "\n"
end
function normalizeToLF(text::String)::String
return replace(text, "\r\n" => "\n", "\r" => "\n")
end
function restoreLineEndings(text::String, ending::String)::String
if ending == "\r\n"
return replace(text, "\n" => "\r\n")
end
return text
end
function normalizeForFuzzyMatch(text::String)::String
# TODO: Implement fuzzy matching normalization
return text
end
function splitLinesWithEndings(content::String)::Vector{String}
# TODO: Implement line splitting with endings
return split(content, "\n")
end
function applyEditsToNormalizedContent(
normalized_content::String,
edits::Vector{Any},
path::String,
)::Tuple{String, String}
# TODO: Implement edit application
return normalized_content, normalized_content
end
function generateUnifiedPatch(path::String, old_content::String, new_content::String, context_lines::Int64=4)::String
# TODO: Implement unified patch generation
return ""
end
function generateDiffString(
old_content::String,
new_content::String,
context_lines::Int64=4,
)::Tuple{String, Union{Int64, Nothing}}
# TODO: Implement diff string generation
return "", nothing
end
end
@@ -0,0 +1,59 @@
"""
tools/file_mutation_queue.jl - File mutation queue
This module provides file mutation serialization for safe concurrent file writes.
"""
module FileMutationQueue
using ..Types: *
using ..HarnessTypes: ExecutionEnv, getOrThrow, FileError, Result
# ============================================================================
# Mutation queue state
# ============================================================================
mutable struct MutationQueueState
queues::Dict{String, Any}
registration::Any
end
# Global state
const states = Dict{ExecutionEnv, MutationQueueState}()
function getState(env::ExecutionEnv)::MutationQueueState
if !haskey(states, env)
states[env] = MutationQueueState(Dict{String, Any}(), nothing)
end
return states[env]
end
# ============================================================================
# File mutation queue helpers
# ============================================================================
async function getMutationQueueKey(env::ExecutionEnv, path::String)::String
absolute_path = getOrThrow(getOrThrow(absolutePath(env, path), "Failed to get absolute path"))
canonical_path = canonicalPath(env, absolute_path, nothing)
if canonical_path.ok
return canonical_path.value
end
if canonical_path.error.code in ("not_found", "not_supported")
return absolute_path
end
throw(canonical_path.error)
end
# ============================================================================
# Main function - serialize file mutations
# ============================================================================
function withFileMutationQueue{T}(env::ExecutionEnv, path::String, fn::Function)::T
state = getState(env)
# TODO: Implement proper async queueing
# This is a simplified version
return fn()
end
end
@@ -0,0 +1,66 @@
"""
tools/image.jl - Image utilities
This module provides image detection and encoding utilities.
"""
module Image
using ..Types: *
function detectSupportedImageMimeType(buffer::Vector{UInt8})::Union{String, Nothing}
if length(buffer) >= 3 && buffer[1:3] == [0xff, 0xd8, 0xff]
if buffer[4] == 0xf7
return nothing
end
return "image/jpeg"
end
if length(buffer) >= 8 && buffer[1:8] == [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
return "image/png"
end
if length(buffer) >= 3 && buffer[1:3] == [0x47, 0x49, 0x46]
return "image/gif"
end
if length(buffer) >= 12 && buffer[1:4] == [0x52, 0x49, 0x46, 0x46] && buffer[9:12] == [0x57, 0x45, 0x42, 0x50]
return "image/webp"
end
if length(buffer) >= 2 && buffer[1:2] == [0x42, 0x4d]
return "image/bmp"
end
return nothing
end
function encodeBase64(bytes::Vector{UInt8})::String
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
output = ""
for i in 1:3:length(bytes)
first_byte = i <= length(bytes) ? bytes[i] : 0
second_byte = i+1 <= length(bytes) ? bytes[i+1] : 0
third_byte = i+2 <= length(bytes) ? bytes[i+2] : 0
output *= alphabet[first_byte >> 2 + 1]
output *= alphabet[(((first_byte & 0x03) << 4) | ((second_byte >> 4) & 0x0f)) + 1]
if i+1 <= length(bytes)
output *= alphabet[(((second_byte & 0x0f) << 2) | ((third_byte >> 6) & 0x03)) + 1]
else
output *= "="
end
if i+2 <= length(bytes)
output *= alphabet[third_byte & 0x3f + 1]
else
output *= "="
end
end
return output
end
end
@@ -0,0 +1,35 @@
"""
tools/index.jl - Tool exports
This module exports all tools.
"""
module ToolsIndex
using ..Tools.Bash: createBashTool
using ..Tools.Read: createReadTool
using ..Tools.Write: createWriteTool
using ..Tools.Edit: createEditTool
using ..Tools.Edit: EditToolDetails, EditToolInput
using ..Tools.Read: ReadToolDetails, ReadToolInput, ReadToolOptions, ReadImageProcessor, ReadImageProcessorResult
export
createBashTool,
createReadTool,
createWriteTool,
createEditTool,
BashExecution,
BashPrepare,
BashToolDetails,
BashToolInput,
BashToolOptions,
EditToolDetails,
EditToolInput,
ReadToolDetails,
ReadToolInput,
ReadToolOptions,
ReadImageProcessor,
ReadImageProcessorResult,
WriteToolInput
end
@@ -0,0 +1,44 @@
"""
tools/path_utils.jl - Path resolution utilities
This module provides path resolution utilities for tools.
"""
module PathUtils
using ..Types: *
using ..HarnessTypes: ExecutionEnv, getOrThrow, FileError, Result
function normalizeToolPath(path::String)::String
normalized = replace(path, r"[\u00A0\u2000-\u200A\u202F\u205F\u3000]" => " ")
if startswith(normalized, "@")
return normalized[2:end]
end
return normalized
end
function resolveToolPath(env::ExecutionEnv, path::String, signal::Union{Any, Nothing}=nothing)::String
return getOrThrow(getOrThrow(absolutePath(env, normalizeToolPath(path), signal), "Failed to resolve path"))
end
function resolveReadToolPath(env::ExecutionEnv, path::String, signal::Union{Any, Nothing}=nothing)::String
resolved = getOrThrow(getOrThrow(absolutePath(env, normalizeToolPath(path), signal), "Failed to resolve path"))
variants = String[
resolved,
replace(resolved, r" (AM|PM)\."i => " $1."),
normalized = replace(resolved, NFC => NFD),
replace(resolved, "'" => "\u2019"),
replace(replace(resolved, NFC => NFD), "'" => "\u2019"),
]
for variant in variants
if getOrThrow(getOrThrow(exists(env, variant, signal), "Failed to check existence"), "Not found")
return variant
end
end
return resolved
end
end
@@ -0,0 +1,35 @@
"""
tools/read.jl - File read tool
This module provides the file read tool for AgentCore.
"""
module Read
using ..Types: *
mutable struct ReadToolDetails
truncation::Union{Any, Nothing}
end
mutable struct ReadToolOptions
auto_resize_images::Bool
image_processor::Union{Any, Nothing}
end
function createReadTool{TContext}(options::Union{ReadToolOptions, Nothing}=nothing) where TContext
return AgentTool(
"read",
"read",
"Read the contents of a file.",
Dict{String, Any}(),
(tool_call_id, params, signal, on_update, context) -> begin
# TODO: Implement read execution
return AgentToolResult([TextContent("File read successfully")], nothing, nothing, nothing, nothing)
end,
nothing,
nothing,
)
end
end
@@ -0,0 +1,26 @@
"""
tools/write.jl - File write tool
This module provides the file write tool for AgentCore.
"""
module Write
using ..Types: *
function createWriteTool{TContext}() where TContext
return AgentTool(
"write",
"write",
"Write content to a file.",
Dict{String, Any}(),
(tool_call_id, params, signal, on_update, context) -> begin
# TODO: Implement write execution
return AgentToolResult([TextContent("File written successfully")], nothing, nothing, nothing, nothing)
end,
nothing,
nothing,
)
end
end
@@ -0,0 +1,588 @@
"""
types.jl - Core types for AgentCore
This module defines the fundamental types used throughout the AgentCore package.
"""
module Types
using Dates
using UUIDs
using JSON3
using Unicode
# ============================================================================
# Basic type aliases
# ============================================================================
const Timestamp = Int64
# ============================================================================
# Thinking level enum
# ============================================================================
@enum ThinkingLevel begin
THINKING_OFF = "off"
THINKING_MINIMAL = "minimal"
THINKING_LOW = "low"
THINKING_MEDIUM = "medium"
THINKING_HIGH = "high"
THINKING_XHIGH = "xhigh"
THINKING_MAX = "max"
end
# ============================================================================
# Tool execution modes
# ============================================================================
@enum ToolExecutionMode begin
EXECUTION_SEQUENTIAL = "sequential"
EXECUTION_PARALLEL = "parallel"
end
# ============================================================================
# Queue drain modes
# ============================================================================
@enum QueueMode begin
QUEUE_ALL = "all"
QUEUE_ONE_AT_A_TIME = "one-at-a-time"
end
# ============================================================================
# Message content types
# ============================================================================
abstract type MessageContent end
struct TextContent <: MessageContent
text::String
end
struct ImageContent <: MessageContent
data::String
mime_type::String
end
# ============================================================================
# Message types
# ============================================================================
abstract type Message end
struct UserMessage <: Message
role::String
content::Vector{MessageContent}
timestamp::Timestamp
end
struct AssistantMessage <: Message
role::String
content::Vector{MessageContent}
api::String
provider::String
model::String
usage::Usage
stop_reason::String
error_message::Union{String, Nothing}
timestamp::Timestamp
end
struct ToolResultMessage <: Message
role::String
tool_call_id::String
tool_name::String
content::Vector{MessageContent}
details::Any
usage::Union{Usage, Nothing}
added_tool_names::Union{Vector{String}, Nothing}
is_error::Bool
timestamp::Timestamp
end
# ============================================================================
# Usage statistics
# ============================================================================
struct UsageCost
input::Float64
output::Float64
cache_read::Float64
cache_write::Float64
total::Float64
end
struct Usage
input::Int64
output::Int64
cache_read::Int64
cache_write::Int64
total_tokens::Int64
cost::UsageCost
end
# ============================================================================
# Model types
# ============================================================================
struct ModelCost
input::Float64
output::Float64
cache_read::Float64
cache_write::Float64
end
struct Model{Api}
id::String
name::String
api::Api
provider::String
base_url::String
reasoning::Bool
input::Vector{String}
cost::ModelCost
context_window::Int64
max_tokens::Int64
end
# ============================================================================
# Agent message union type
# ============================================================================
abstract type AgentMessage end
# Custom message types can extend this via multiple dispatch
struct CustomMessage <: AgentMessage
message::AgentMessage
custom_type::String
end
# ============================================================================
# Tool types
# ============================================================================
struct AgentToolResult{T}
content::Vector{MessageContent}
details::T
usage::Union{Usage, Nothing}
added_tool_names::Union{Vector{String}, Nothing}
terminate::Union{Bool, Nothing}
end
struct AgentTool{TParameters, TDetails}
name::String
label::String
description::String
parameters::TParameters
execute::Function
prepare_arguments::Union{Function, Nothing}
execution_mode::Union{ToolExecutionMode, Nothing}
end
# ============================================================================
# Agent context
# ============================================================================
struct AgentContext
system_prompt::String
messages::Vector{AgentMessage}
tools::Union{Vector{AgentTool}, Nothing}
end
# ============================================================================
# Event types
# ============================================================================
abstract type AgentEvent end
struct AgentStartEvent <: AgentEvent end
struct AgentEndEvent <: AgentEvent
messages::Vector{AgentMessage}
end
struct TurnStartEvent <: AgentEvent end
struct TurnEndEvent <: AgentEvent
message::AgentMessage
tool_results::Vector{ToolResultMessage}
end
struct MessageStartEvent <: AgentEvent
message::AgentMessage
end
struct MessageUpdateEvent <: AgentEvent
message::AgentMessage
assistant_message_event::Any
end
struct MessageEndEvent <: AgentEvent
message::AgentMessage
end
struct ToolExecutionStartEvent <: AgentEvent
tool_call_id::String
tool_name::String
args::Any
end
struct ToolExecutionUpdateEvent <: AgentEvent
tool_call_id::String
tool_name::String
args::Any
partial_result::Any
end
struct ToolExecutionEndEvent <: AgentEvent
tool_call_id::String
tool_name::String
result::Any
is_error::Bool
end
# ============================================================================
# Assistant message event types
# ============================================================================
abstract type AssistantMessageEvent end
struct StartEvent <: AssistantMessageEvent
partial::AssistantMessage
end
struct TextStartEvent <: AssistantMessageEvent
content_index::Int64
partial::AssistantMessage
end
struct TextDeltaEvent <: AssistantMessageEvent
content_index::Int64
delta::String
partial::AssistantMessage
end
struct TextEndEvent <: AssistantMessageEvent
content_index::Int64
content::String
partial::AssistantMessage
end
struct DoneEvent <: AssistantMessageEvent
reason::String
usage::Usage
message::AssistantMessage
end
struct ErrorEvent <: AssistantMessageEvent
reason::String
error_message::Union{String, Nothing}
usage::Usage
error::AssistantMessage
end
# ============================================================================
# Agent state
# ============================================================================
mutable struct AgentState
system_prompt::String
model::Model
thinking_level::ThinkingLevel
tools::Vector{AgentTool}
messages::Vector{AgentMessage}
is_streaming::Bool
streaming_message::Union{AgentMessage, Nothing}
pending_tool_calls::Set{String}
error_message::Union{String, Nothing}
function AgentState(
system_prompt::String="",
model::Model=Model("", "", "unknown", "unknown", "", false, String[], ModelCost(0.0, 0.0, 0.0, 0.0), 0, 0),
thinking_level::ThinkingLevel=THINKING_OFF,
tools::Vector{AgentTool}=AgentTool[],
messages::Vector{AgentMessage}=AgentMessage[],
)
new(
system_prompt,
model,
thinking_level,
copy(tools),
copy(messages),
false,
nothing,
Set{String}(),
nothing,
)
end
end
# ============================================================================
# Tool call types
# ============================================================================
struct ToolCall
type::String
id::String
name::String
arguments::Dict{String, Any}
partial_json::Union{String, Nothing}
end
# ============================================================================
# Context transform types
# ============================================================================
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
# ============================================================================
# Before/After tool call types
# ============================================================================
struct BeforeToolCallContext
assistant_message::AssistantMessage
tool_call::ToolCall
args::Any
context::AgentContext
end
struct BeforeToolCallResult
block::Union{Bool, Nothing}
reason::Union{String, Nothing}
end
struct AfterToolCallContext
assistant_message::AssistantMessage
tool_call::ToolCall
args::Any
result::AgentToolResult
is_error::Bool
context::AgentContext
end
struct AfterToolCallResult
content::Union{Vector{MessageContent}, Nothing}
details::Union{Any, Nothing}
is_error::Union{Bool, Nothing}
usage::Union{Usage, Nothing}
terminate::Union{Bool, Nothing}
end
# ============================================================================
# Stream function signature
# ============================================================================
const StreamFn = Function
# ============================================================================
# File types
# ============================================================================
struct FileKind
value::String
end
const FILE_KIND_FILE = FileKind("file")
const FILE_KIND_DIRECTORY = FileKind("directory")
const FILE_KIND_SYMLINK = FileKind("symlink")
struct FileInfo
name::String
path::String
kind::FileKind
size::Int64
mtime_ms::Int64
end
struct FileError <: Exception
code::String
message::String
path::Union{String, Nothing}
cause::Union{Exception, Nothing}
end
struct ExecutionError <: Exception
code::String
message::String
cause::Union{Exception, Nothing}
end
struct CompactionError <: Exception
code::String
message::String
cause::Union{Exception, Nothing}
end
struct BranchSummaryError <: Exception
code::String
message::String
cause::Union{Exception, Nothing}
end
struct SessionError <: Exception
code::String
message::String
cause::Union{Exception, Nothing}
end
struct AgentHarnessError <: Exception
code::String
message::String
cause::Union{Exception, Nothing}
end
# ============================================================================
# Session tree entry types
# ============================================================================
abstract type SessionTreeEntry end
struct SessionTreeEntryBase
type::String
id::String
parent_id::Union{String, Nothing}
timestamp::String
end
struct MessageEntry <: SessionTreeEntry
base::SessionTreeEntryBase
message::AgentMessage
end
struct ThinkingLevelChangeEntry <: SessionTreeEntry
base::SessionTreeEntryBase
thinking_level::String
end
struct ModelChangeEntry <: SessionTreeEntry
base::SessionTreeEntryBase
provider::String
model_id::String
end
struct ActiveToolsChangeEntry <: SessionTreeEntry
base::SessionTreeEntryBase
active_tool_names::Vector{String}
end
struct CompactionEntry{T} <: SessionTreeEntry
base::SessionTreeEntryBase
summary::String
first_kept_entry_id::Union{String, Nothing}
tokens_before::Int64
retained_tail::Union{Vector{AgentMessage}, Nothing}
details::Union{T, Nothing}
usage::Union{Usage, Nothing}
from_hook::Bool
end
struct BranchSummaryEntry{T} <: SessionTreeEntry
base::SessionTreeEntryBase
from_id::String
summary::String
details::Union{T, Nothing}
usage::Union{Usage, Nothing}
from_hook::Bool
end
struct CustomEntry{T} <: SessionTreeEntry
base::SessionTreeEntryBase
custom_type::String
data::Union{T, Nothing}
end
struct CustomMessageEntry{T} <: SessionTreeEntry
base::SessionTreeEntryBase
custom_type::String
content::String
details::Union{T, Nothing}
display::Bool
end
struct LabelEntry <: SessionTreeEntry
base::SessionTreeEntryBase
target_id::String
label::Union{String, Nothing}
end
struct SessionInfoEntry <: SessionTreeEntry
base::SessionTreeEntryBase
name::Union{String, Nothing}
end
struct LeafEntry <: SessionTreeEntry
base::SessionTreeEntryBase
target_id::Union{String, Nothing}
end
# ============================================================================
# Session context
# ============================================================================
struct SessionContext
messages::Vector{AgentMessage}
thinking_level::String
model::Union{Dict{String, String}, Nothing}
active_tool_names::Union{Vector{String}, Nothing}
end
# ============================================================================
# Session stats
# ============================================================================
struct SessionStats
message_count::Int64
cached_tokens::Int64
uncached_tokens::Int64
total_tokens::Int64
cost_total::Float64
end
# ============================================================================
# Session metadata
# ============================================================================
abstract type SessionMetadata end
struct JsonlSessionMetadata <: SessionMetadata
id::String
created_at::String
cwd::String
path::String
parent_session_path::Union{String, Nothing}
metadata::Union{Dict{String, Any}, Nothing}
end
# ============================================================================
# Session storage interface
# ============================================================================
abstract type SessionStorage{T<:SessionMetadata} end
# ============================================================================
# Session repo interface
# ============================================================================
abstract type SessionRepo<
TMetadata<:SessionMetadata,
TCreateOptions,
TListOptions
> end
# ============================================================================
# Helper functions
# ============================================================================
function create_timestamp()::String
return string(Dates.now(Dates.UTC))
end
function uuidv7()::String
return string(UUIDs.uuid7())
end
function uuidstring()::String
return string(UUIDs.uuid4())
end
function tempname()::String
return tempname()
end
end