update
This commit is contained in:
@@ -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, "&" => "&")
|
||||
result = replace(result, "<" => "<")
|
||||
result = replace(result, ">" => ">")
|
||||
result = replace(result, "\"" => """)
|
||||
result = replace(result, "'" => "'")
|
||||
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
|
||||
Reference in New Issue
Block a user