add doc.go files; decompose agent package
doc.go: - agent, backend, session, fs, bypass, toolclient (olliesrv) - server, sandbox, registry (toolsrv) - protocol, metadata (toolsrv shared) - log, util, skills agent package decomposition: - chat.go: chat log, streaming, plan methods - state.go: State, Reply, WaitChange, SignalCh, emit - peer.go: AddPeer, RemovePeer, Peers - subagent.go: Depth, IncChildren, DecChildren, ActiveChildren - agent.go: 881 → 638 lines (core struct, identity, backend, runtime)
This commit is contained in:
parent
fa219e06aa
commit
9b7de31a07
|
|
@ -141,147 +141,6 @@ func (ag *Agent) ID() string { return ag.id }
|
||||||
// ParentID returns the immutable ID of the agent that spawned this agent.
|
// ParentID returns the immutable ID of the agent that spawned this agent.
|
||||||
func (ag *Agent) ParentID() string { return ag.parentID }
|
func (ag *Agent) ParentID() string { return ag.parentID }
|
||||||
|
|
||||||
// Depth returns the sub-agent depth (0=top-level).
|
|
||||||
func (ag *Agent) Depth() int { return ag.depth }
|
|
||||||
|
|
||||||
// SetDepth sets the sub-agent depth.
|
|
||||||
func (ag *Agent) SetDepth(d int) { ag.depth = d }
|
|
||||||
|
|
||||||
// IncChildren increments the active child sub-agent counter.
|
|
||||||
func (ag *Agent) IncChildren() { ag.activeChildren.Add(1) }
|
|
||||||
|
|
||||||
// DecChildren decrements the active child sub-agent counter.
|
|
||||||
func (ag *Agent) DecChildren() { ag.activeChildren.Add(-1) }
|
|
||||||
|
|
||||||
// ActiveChildren returns the number of currently-running child sub-agents.
|
|
||||||
func (ag *Agent) ActiveChildren() int32 { return ag.activeChildren.Load() }
|
|
||||||
|
|
||||||
// AddPeer adds a peer agent by name.
|
|
||||||
func (ag *Agent) AddPeer(name string) {
|
|
||||||
ag.peerMu.Lock()
|
|
||||||
if ag.peers == nil {
|
|
||||||
ag.peers = make(map[string]struct{})
|
|
||||||
}
|
|
||||||
ag.peers[name] = struct{}{}
|
|
||||||
ag.peerMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemovePeer removes a peer agent by name.
|
|
||||||
func (ag *Agent) RemovePeer(name string) {
|
|
||||||
ag.peerMu.Lock()
|
|
||||||
delete(ag.peers, name)
|
|
||||||
ag.peerMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Peers returns the sorted list of peer agent names.
|
|
||||||
func (ag *Agent) Peers() []string {
|
|
||||||
ag.peerMu.RLock()
|
|
||||||
defer ag.peerMu.RUnlock()
|
|
||||||
out := make([]string, 0, len(ag.peers))
|
|
||||||
for name := range ag.peers {
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
slices.Sort(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Chat log methods ---
|
|
||||||
|
|
||||||
const maxChatLogBytes = 16 * 1024 * 1024
|
|
||||||
|
|
||||||
// AppendChat appends data to the chat log and notifies stream readers.
|
|
||||||
func (ag *Agent) AppendChat(data []byte) {
|
|
||||||
if len(data) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ag.chatMu.Lock()
|
|
||||||
ag.chatLog = append(ag.chatLog, data...)
|
|
||||||
if len(ag.chatLog) > maxChatLogBytes {
|
|
||||||
drop := len(ag.chatLog) - maxChatLogBytes
|
|
||||||
ag.chatLog = append([]byte(nil), ag.chatLog[drop:]...)
|
|
||||||
ag.chatStart += drop
|
|
||||||
}
|
|
||||||
ag.chatVers++
|
|
||||||
ag.chatMu.Unlock()
|
|
||||||
ag.chatCond.Broadcast()
|
|
||||||
ag.chatSignalMu.Lock()
|
|
||||||
close(ag.chatSignalCh)
|
|
||||||
ag.chatSignalCh = make(chan struct{})
|
|
||||||
ag.chatSignalMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureTrailingNewline appends a newline if the log doesn't end with one.
|
|
||||||
func (ag *Agent) EnsureTrailingNewline() {
|
|
||||||
ag.chatMu.Lock()
|
|
||||||
if len(ag.chatLog) > 0 && ag.chatLog[len(ag.chatLog)-1] != '\n' {
|
|
||||||
ag.chatLog = append(ag.chatLog, '\n')
|
|
||||||
}
|
|
||||||
ag.chatVers++
|
|
||||||
ag.chatMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChatMu returns the chat log mutex for external locking (streaming).
|
|
||||||
func (ag *Agent) ChatMu() *sync.RWMutex { return &ag.chatMu }
|
|
||||||
|
|
||||||
// ChatCond returns the condvar for blocking chat readers.
|
|
||||||
func (ag *Agent) ChatCond() *sync.Cond { return ag.chatCond }
|
|
||||||
|
|
||||||
// ChatSignal returns the current chat signal channel (closed on new chat data).
|
|
||||||
func (ag *Agent) ChatSignal() <-chan struct{} {
|
|
||||||
ag.chatSignalMu.Lock()
|
|
||||||
ch := ag.chatSignalCh
|
|
||||||
ag.chatSignalMu.Unlock()
|
|
||||||
return ch
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChatLog returns the raw chat log bytes (caller must hold ChatMu.RLock).
|
|
||||||
func (ag *Agent) ChatLog() []byte { return ag.chatLog }
|
|
||||||
|
|
||||||
// ChatRead returns new chat data since the given offset (base).
|
|
||||||
// Returns (data, nextBase, error). If no new data, returns empty data.
|
|
||||||
func (ag *Agent) ChatRead(base string) ([]byte, string, error) {
|
|
||||||
var offset int
|
|
||||||
if base != "" {
|
|
||||||
fmt.Sscanf(base, "%d", &offset)
|
|
||||||
} else {
|
|
||||||
ag.chatMu.RLock()
|
|
||||||
offset = len(ag.chatLog) + ag.chatStart
|
|
||||||
ag.chatMu.RUnlock()
|
|
||||||
}
|
|
||||||
ag.chatMu.RLock()
|
|
||||||
if offset < ag.chatStart {
|
|
||||||
offset = ag.chatStart
|
|
||||||
}
|
|
||||||
localOffset := offset - ag.chatStart
|
|
||||||
log := ag.chatLog
|
|
||||||
if len(log) <= localOffset {
|
|
||||||
ag.chatMu.RUnlock()
|
|
||||||
return nil, fmt.Sprintf("%d", offset), nil
|
|
||||||
}
|
|
||||||
data := make([]byte, len(log)-localOffset)
|
|
||||||
copy(data, log[localOffset:])
|
|
||||||
newOffset := ag.chatStart + len(log)
|
|
||||||
ag.chatMu.RUnlock()
|
|
||||||
return data, fmt.Sprintf("%d", newOffset), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plan returns a copy of the plan.
|
|
||||||
func (ag *Agent) Plan() []byte {
|
|
||||||
ag.chatMu.RLock()
|
|
||||||
p := make([]byte, len(ag.plan))
|
|
||||||
copy(p, ag.plan)
|
|
||||||
ag.chatMu.RUnlock()
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetPlan replaces the plan.
|
|
||||||
func (ag *Agent) SetPlan(data []byte) {
|
|
||||||
ag.chatMu.Lock()
|
|
||||||
ag.plan = make([]byte, len(data))
|
|
||||||
copy(ag.plan, data)
|
|
||||||
ag.chatMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// BackendName returns the name of the active backend.
|
// BackendName returns the name of the active backend.
|
||||||
func (ag *Agent) BackendName() string {
|
func (ag *Agent) BackendName() string {
|
||||||
if ag.runtime.Backend == nil {
|
if ag.runtime.Backend == nil {
|
||||||
|
|
@ -323,102 +182,6 @@ func (ag *Agent) SwitchBackend(name string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// State returns the agent's current execution state.
|
|
||||||
func (ag *Agent) State() string {
|
|
||||||
ag.stateMu.RLock()
|
|
||||||
s := ag.state
|
|
||||||
ag.stateMu.RUnlock()
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetState sets the agent's execution state and notifies waiters.
|
|
||||||
func (ag *Agent) SetState(state string) {
|
|
||||||
ag.stateMu.Lock()
|
|
||||||
ag.state = state
|
|
||||||
ag.stateMu.Unlock()
|
|
||||||
ag.notifyChange()
|
|
||||||
if ag.onStateChange != nil {
|
|
||||||
ag.onStateChange(ag.id, state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reply returns the agent's last assistant response.
|
|
||||||
func (ag *Agent) Reply() string {
|
|
||||||
ag.stateMu.RLock()
|
|
||||||
r := ag.reply
|
|
||||||
ag.stateMu.RUnlock()
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetOnStateChange sets a callback invoked whenever agent state changes.
|
|
||||||
func (ag *Agent) SetOnStateChange(fn func(agentID, state string)) {
|
|
||||||
ag.onStateChange = fn
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetReply sets the agent's last response.
|
|
||||||
func (ag *Agent) setReply(reply string) {
|
|
||||||
ag.stateMu.Lock()
|
|
||||||
ag.reply = reply
|
|
||||||
ag.stateMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// notifyChange wakes all goroutines waiting on state changes.
|
|
||||||
func (ag *Agent) notifyChange() {
|
|
||||||
ag.signalMu.Lock()
|
|
||||||
close(ag.signalCh)
|
|
||||||
ag.signalCh = make(chan struct{})
|
|
||||||
ag.signalMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SignalCh returns the current signal channel (closed on any change).
|
|
||||||
func (ag *Agent) SignalCh() <-chan struct{} {
|
|
||||||
ag.signalMu.Lock()
|
|
||||||
ch := ag.signalCh
|
|
||||||
ag.signalMu.Unlock()
|
|
||||||
return ch
|
|
||||||
}
|
|
||||||
|
|
||||||
// WaitChange blocks until the agent's state differs from current.
|
|
||||||
// Returns the new value and true, or ("", false) if ctx is cancelled.
|
|
||||||
func (ag *Agent) WaitChange(ctx context.Context, field, current string) (string, bool) {
|
|
||||||
for {
|
|
||||||
// Snapshot the signal channel before reading state.
|
|
||||||
ag.signalMu.Lock()
|
|
||||||
ch := ag.signalCh
|
|
||||||
ag.signalMu.Unlock()
|
|
||||||
|
|
||||||
var val string
|
|
||||||
switch field {
|
|
||||||
case WatchState:
|
|
||||||
val = ag.State()
|
|
||||||
case WatchFeed:
|
|
||||||
val = ag.Feed.Hash()
|
|
||||||
if val == "" {
|
|
||||||
val = current // no data yet — block
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if val != current {
|
|
||||||
return val, true
|
|
||||||
}
|
|
||||||
// Wait for either a state change or context cancellation.
|
|
||||||
select {
|
|
||||||
case <-ch:
|
|
||||||
// Changed — loop to re-check.
|
|
||||||
case <-ctx.Done():
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// emit sends an event to the agent's output handler.
|
|
||||||
func (ag *Agent) emit(ev Event) {
|
|
||||||
if ag.output != nil {
|
|
||||||
ag.output(ev)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetToolServer updates the tool server connection and factory.
|
// SetToolServer updates the tool server connection and factory.
|
||||||
// Used when resuming a paused session that was restored without infra.
|
// Used when resuming a paused session that was restored without infra.
|
||||||
func (ag *Agent) SetToolServer(newToolServer func() *toolclient.ToolsrvConn, conn *toolclient.ToolsrvConn) {
|
func (ag *Agent) SetToolServer(newToolServer func() *toolclient.ToolsrvConn, conn *toolclient.ToolsrvConn) {
|
||||||
|
|
@ -582,12 +345,6 @@ type actionHandle struct {
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatchField names supported by Agent.WaitChange.
|
|
||||||
const (
|
|
||||||
WatchState = "state"
|
|
||||||
WatchFeed = "feed"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SetSessionEnv injects session env vars into the execute server.
|
// SetSessionEnv injects session env vars into the execute server.
|
||||||
func (ag *Agent) SetSessionEnv(sessionID string) {
|
func (ag *Agent) SetSessionEnv(sessionID string) {
|
||||||
if ag.runtime == nil || ag.runtime.ToolServer == nil {
|
if ag.runtime == nil || ag.runtime.ToolServer == nil {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Chat log methods ---
|
||||||
|
|
||||||
|
const maxChatLogBytes = 16 * 1024 * 1024
|
||||||
|
|
||||||
|
// AppendChat appends data to the chat log and notifies stream readers.
|
||||||
|
func (ag *Agent) AppendChat(data []byte) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ag.chatMu.Lock()
|
||||||
|
ag.chatLog = append(ag.chatLog, data...)
|
||||||
|
if len(ag.chatLog) > maxChatLogBytes {
|
||||||
|
drop := len(ag.chatLog) - maxChatLogBytes
|
||||||
|
ag.chatLog = append([]byte(nil), ag.chatLog[drop:]...)
|
||||||
|
ag.chatStart += drop
|
||||||
|
}
|
||||||
|
ag.chatVers++
|
||||||
|
ag.chatMu.Unlock()
|
||||||
|
ag.chatCond.Broadcast()
|
||||||
|
ag.chatSignalMu.Lock()
|
||||||
|
close(ag.chatSignalCh)
|
||||||
|
ag.chatSignalCh = make(chan struct{})
|
||||||
|
ag.chatSignalMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureTrailingNewline appends a newline if the log doesn't end with one.
|
||||||
|
func (ag *Agent) EnsureTrailingNewline() {
|
||||||
|
ag.chatMu.Lock()
|
||||||
|
if len(ag.chatLog) > 0 && ag.chatLog[len(ag.chatLog)-1] != '\n' {
|
||||||
|
ag.chatLog = append(ag.chatLog, '\n')
|
||||||
|
}
|
||||||
|
ag.chatVers++
|
||||||
|
ag.chatMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatMu returns the chat log mutex for external locking (streaming).
|
||||||
|
func (ag *Agent) ChatMu() *sync.RWMutex { return &ag.chatMu }
|
||||||
|
|
||||||
|
// ChatCond returns the condvar for blocking chat readers.
|
||||||
|
func (ag *Agent) ChatCond() *sync.Cond { return ag.chatCond }
|
||||||
|
|
||||||
|
// ChatSignal returns the current chat signal channel (closed on new chat data).
|
||||||
|
func (ag *Agent) ChatSignal() <-chan struct{} {
|
||||||
|
ag.chatSignalMu.Lock()
|
||||||
|
ch := ag.chatSignalCh
|
||||||
|
ag.chatSignalMu.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatLog returns the raw chat log bytes (caller must hold ChatMu.RLock).
|
||||||
|
func (ag *Agent) ChatLog() []byte { return ag.chatLog }
|
||||||
|
|
||||||
|
// ChatRead returns new chat data since the given offset (base).
|
||||||
|
// Returns (data, nextBase, error). If no new data, returns empty data.
|
||||||
|
func (ag *Agent) ChatRead(base string) ([]byte, string, error) {
|
||||||
|
var offset int
|
||||||
|
if base != "" {
|
||||||
|
fmt.Sscanf(base, "%d", &offset)
|
||||||
|
} else {
|
||||||
|
ag.chatMu.RLock()
|
||||||
|
offset = len(ag.chatLog) + ag.chatStart
|
||||||
|
ag.chatMu.RUnlock()
|
||||||
|
}
|
||||||
|
ag.chatMu.RLock()
|
||||||
|
if offset < ag.chatStart {
|
||||||
|
offset = ag.chatStart
|
||||||
|
}
|
||||||
|
localOffset := offset - ag.chatStart
|
||||||
|
log := ag.chatLog
|
||||||
|
if len(log) <= localOffset {
|
||||||
|
ag.chatMu.RUnlock()
|
||||||
|
return nil, fmt.Sprintf("%d", offset), nil
|
||||||
|
}
|
||||||
|
data := make([]byte, len(log)-localOffset)
|
||||||
|
copy(data, log[localOffset:])
|
||||||
|
newOffset := ag.chatStart + len(log)
|
||||||
|
ag.chatMu.RUnlock()
|
||||||
|
return data, fmt.Sprintf("%d", newOffset), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Plan methods ---
|
||||||
|
|
||||||
|
// Plan returns a copy of the plan.
|
||||||
|
func (ag *Agent) Plan() []byte {
|
||||||
|
ag.chatMu.RLock()
|
||||||
|
p := make([]byte, len(ag.plan))
|
||||||
|
copy(p, ag.plan)
|
||||||
|
ag.chatMu.RUnlock()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPlan replaces the plan.
|
||||||
|
func (ag *Agent) SetPlan(data []byte) {
|
||||||
|
ag.chatMu.Lock()
|
||||||
|
ag.plan = make([]byte, len(data))
|
||||||
|
copy(ag.plan, data)
|
||||||
|
ag.chatMu.Unlock()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
// Package agent implements the core agent loop for ollie.
|
||||||
|
//
|
||||||
|
// An Agent owns history, runtime state, and the model/tool execution loop.
|
||||||
|
// It processes user prompts, streams LLM responses, executes tool calls,
|
||||||
|
// and manages conversation history with automatic compaction.
|
||||||
|
//
|
||||||
|
// # Core Types
|
||||||
|
//
|
||||||
|
// Agent is the main type. Create one via NewAgent, submit prompts via
|
||||||
|
// Submit, and observe events via the EventHandler callback. State
|
||||||
|
// transitions are visible through State() and WaitChange().
|
||||||
|
//
|
||||||
|
// History tracks messages, token usage, cost, and supports multi-zone
|
||||||
|
// compaction (cold/warm/hot) to manage context window limits.
|
||||||
|
//
|
||||||
|
// Runtime holds the active preamble, tool schemas, and backend reference.
|
||||||
|
// It is rebuilt on profile switches or tool registry changes.
|
||||||
|
//
|
||||||
|
// # Execution Model
|
||||||
|
//
|
||||||
|
// Submit queues a prompt. The agent loop streams the LLM response, parses
|
||||||
|
// tool calls (native or text-based), executes them through toolsrv with
|
||||||
|
// scope-based conflict detection, appends results to history, and repeats
|
||||||
|
// until the model produces a final response or an error limit is reached.
|
||||||
|
//
|
||||||
|
// Tool calls are batched for parallel execution when their scopes don't
|
||||||
|
// conflict: reads run in parallel, writes to different paths run in
|
||||||
|
// parallel, but writes to the same path or global-scope tools serialize.
|
||||||
|
//
|
||||||
|
// # Context Management
|
||||||
|
//
|
||||||
|
// The agent tracks token usage and triggers compaction when approaching
|
||||||
|
// context limits. Compaction summarizes older messages while preserving
|
||||||
|
// recent context (hot zone) and decision summaries (warm zone).
|
||||||
|
//
|
||||||
|
// # Sub-Agents
|
||||||
|
//
|
||||||
|
// Sub-agents are transient child agents with independent context windows.
|
||||||
|
// They receive a snapshot of the parent's history and return only their
|
||||||
|
// final reply. Use them for parallel work or to preserve main context.
|
||||||
|
package agent
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import "slices"
|
||||||
|
|
||||||
|
// AddPeer adds a peer agent by name.
|
||||||
|
func (ag *Agent) AddPeer(name string) {
|
||||||
|
ag.peerMu.Lock()
|
||||||
|
if ag.peers == nil {
|
||||||
|
ag.peers = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
ag.peers[name] = struct{}{}
|
||||||
|
ag.peerMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePeer removes a peer agent by name.
|
||||||
|
func (ag *Agent) RemovePeer(name string) {
|
||||||
|
ag.peerMu.Lock()
|
||||||
|
delete(ag.peers, name)
|
||||||
|
ag.peerMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peers returns the sorted list of peer agent names.
|
||||||
|
func (ag *Agent) Peers() []string {
|
||||||
|
ag.peerMu.RLock()
|
||||||
|
defer ag.peerMu.RUnlock()
|
||||||
|
out := make([]string, 0, len(ag.peers))
|
||||||
|
for name := range ag.peers {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// WatchField names supported by Agent.WaitChange.
|
||||||
|
const (
|
||||||
|
WatchState = "state"
|
||||||
|
WatchFeed = "feed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// State returns the agent's current execution state.
|
||||||
|
func (ag *Agent) State() string {
|
||||||
|
ag.stateMu.RLock()
|
||||||
|
s := ag.state
|
||||||
|
ag.stateMu.RUnlock()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetState sets the agent's execution state and notifies waiters.
|
||||||
|
func (ag *Agent) SetState(state string) {
|
||||||
|
ag.stateMu.Lock()
|
||||||
|
ag.state = state
|
||||||
|
ag.stateMu.Unlock()
|
||||||
|
ag.notifyChange()
|
||||||
|
if ag.onStateChange != nil {
|
||||||
|
ag.onStateChange(ag.id, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply returns the agent's last assistant response.
|
||||||
|
func (ag *Agent) Reply() string {
|
||||||
|
ag.stateMu.RLock()
|
||||||
|
r := ag.reply
|
||||||
|
ag.stateMu.RUnlock()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOnStateChange sets a callback invoked whenever agent state changes.
|
||||||
|
func (ag *Agent) SetOnStateChange(fn func(agentID, state string)) {
|
||||||
|
ag.onStateChange = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// setReply sets the agent's last response.
|
||||||
|
func (ag *Agent) setReply(reply string) {
|
||||||
|
ag.stateMu.Lock()
|
||||||
|
ag.reply = reply
|
||||||
|
ag.stateMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyChange wakes all goroutines waiting on state changes.
|
||||||
|
func (ag *Agent) notifyChange() {
|
||||||
|
ag.signalMu.Lock()
|
||||||
|
close(ag.signalCh)
|
||||||
|
ag.signalCh = make(chan struct{})
|
||||||
|
ag.signalMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignalCh returns the current signal channel (closed on any change).
|
||||||
|
func (ag *Agent) SignalCh() <-chan struct{} {
|
||||||
|
ag.signalMu.Lock()
|
||||||
|
ch := ag.signalCh
|
||||||
|
ag.signalMu.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitChange blocks until the agent's state differs from current.
|
||||||
|
// Returns the new value and true, or ("", false) if ctx is cancelled.
|
||||||
|
func (ag *Agent) WaitChange(ctx context.Context, field, current string) (string, bool) {
|
||||||
|
for {
|
||||||
|
// Snapshot the signal channel before reading state.
|
||||||
|
ag.signalMu.Lock()
|
||||||
|
ch := ag.signalCh
|
||||||
|
ag.signalMu.Unlock()
|
||||||
|
|
||||||
|
var val string
|
||||||
|
switch field {
|
||||||
|
case WatchState:
|
||||||
|
val = ag.State()
|
||||||
|
case WatchFeed:
|
||||||
|
val = ag.Feed.Hash()
|
||||||
|
if val == "" {
|
||||||
|
val = current // no data yet — block
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if val != current {
|
||||||
|
return val, true
|
||||||
|
}
|
||||||
|
// Wait for either a state change or context cancellation.
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
// Changed — loop to re-check.
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// emit sends an event to the agent's output handler.
|
||||||
|
func (ag *Agent) emit(ev Event) {
|
||||||
|
if ag.output != nil {
|
||||||
|
ag.output(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
// Depth returns the sub-agent depth (0=top-level).
|
||||||
|
func (ag *Agent) Depth() int { return ag.depth }
|
||||||
|
|
||||||
|
// SetDepth sets the sub-agent depth.
|
||||||
|
func (ag *Agent) SetDepth(d int) { ag.depth = d }
|
||||||
|
|
||||||
|
// IncChildren increments the active child count.
|
||||||
|
func (ag *Agent) IncChildren() { ag.activeChildren.Add(1) }
|
||||||
|
|
||||||
|
// DecChildren decrements the active child count.
|
||||||
|
func (ag *Agent) DecChildren() { ag.activeChildren.Add(-1) }
|
||||||
|
|
||||||
|
// ActiveChildren returns the number of currently-running child sub-agents.
|
||||||
|
func (ag *Agent) ActiveChildren() int32 { return ag.activeChildren.Load() }
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
// Package backend defines the Backend interface and implementations for
|
||||||
|
// LLM providers.
|
||||||
|
//
|
||||||
|
// All backends implement ChatStream for streaming completions. Provider-
|
||||||
|
// specific wire formats are handled inside each implementation; the agent
|
||||||
|
// loop sees only the canonical Message and StreamEvent types.
|
||||||
|
//
|
||||||
|
// # Supported Backends
|
||||||
|
//
|
||||||
|
// - anthropic: Claude models via the Anthropic API
|
||||||
|
// - openai: GPT models via the OpenAI API
|
||||||
|
// - openrouter: Multi-model routing via OpenRouter
|
||||||
|
// - ollama: Local models via Ollama
|
||||||
|
// - gemini: Gemini models via Google AI
|
||||||
|
// - copilot: GitHub Copilot
|
||||||
|
// - kiro: Kiro API
|
||||||
|
//
|
||||||
|
// # Error Types
|
||||||
|
//
|
||||||
|
// Backends return typed errors for conditions the caller can handle:
|
||||||
|
//
|
||||||
|
// - RateLimitError: HTTP 429; includes RetryAfter hint
|
||||||
|
// - TransientError: 5xx or network errors; safe to retry
|
||||||
|
// - ContextOverflowError: request exceeds context window; compact and retry
|
||||||
|
// - ToolUnsupportedError: model doesn't support tool calling
|
||||||
|
//
|
||||||
|
// # Configuration
|
||||||
|
//
|
||||||
|
// Backend selection and credentials come from backends.conf. The file
|
||||||
|
// format is INI-style with sections per backend. Environment variables
|
||||||
|
// provide fallbacks for API keys.
|
||||||
|
package backend
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
// Package bypass provides the sandbox bypass broker for olliesrv.
|
||||||
|
//
|
||||||
|
// When a tool needs to escape the Landlock sandbox (e.g., to access a
|
||||||
|
// path outside the allowed set), it requests bypass approval. The broker
|
||||||
|
// manages pending requests, policy evaluation, user notification, and
|
||||||
|
// resolution.
|
||||||
|
//
|
||||||
|
// # Flow
|
||||||
|
//
|
||||||
|
// 1. Tool calls bypass.Request() with command and path
|
||||||
|
// 2. Broker checks policy — auto-approve, auto-deny, or prompt user
|
||||||
|
// 3. If prompting, request goes to pending queue
|
||||||
|
// 4. Frontend reads bypass/pending and shows approval UI
|
||||||
|
// 5. User approves/denies; frontend writes bypass/resolve
|
||||||
|
// 6. Broker unblocks the waiting tool with the decision
|
||||||
|
//
|
||||||
|
// # Policy
|
||||||
|
//
|
||||||
|
// The policy file ($XDG_CONFIG_HOME/ollie/bypass-policy.conf) contains
|
||||||
|
// glob patterns for auto-approval or auto-denial. Format:
|
||||||
|
//
|
||||||
|
// allow /home/user/projects/*
|
||||||
|
// deny /etc/*
|
||||||
|
//
|
||||||
|
// Paths not matching any rule require interactive approval.
|
||||||
|
//
|
||||||
|
// # Rate Limiting
|
||||||
|
//
|
||||||
|
// The broker rate-limits requests per session to prevent runaway tools
|
||||||
|
// from flooding the user with approval prompts.
|
||||||
|
package bypass
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Package fs defines the 9P namespace for olliesrv.
|
||||||
|
//
|
||||||
|
// The namespace is declared in spec.go using the virtfs EDSL. All session
|
||||||
|
// and agent state is exposed as files: creating sessions, submitting
|
||||||
|
// prompts, reading chat history, and observing state changes are all
|
||||||
|
// file operations.
|
||||||
|
//
|
||||||
|
// # Namespace Structure
|
||||||
|
//
|
||||||
|
// Root level:
|
||||||
|
//
|
||||||
|
// /agents - available agent profile names
|
||||||
|
// /backends - available backend names
|
||||||
|
// /models - available models (backend\tmodel per line)
|
||||||
|
// /ctl - server control (invalidate, kill)
|
||||||
|
// /generate - one-shot generation (rdwr)
|
||||||
|
// /session/ - session directory
|
||||||
|
//
|
||||||
|
// Session level (/session/{name}/):
|
||||||
|
//
|
||||||
|
// env - session environment variables
|
||||||
|
// goal - session objective (write triggers workflow)
|
||||||
|
// agent/ - agents within this session
|
||||||
|
// agent/new - create agent (rdwr)
|
||||||
|
//
|
||||||
|
// Agent level (/session/{name}/agent/{agent}/):
|
||||||
|
//
|
||||||
|
// prompt - submit prompt (write)
|
||||||
|
// chat - conversation history (streaming read)
|
||||||
|
// statewait - block until state changes
|
||||||
|
// plan - agent's plan (survives compaction)
|
||||||
|
// ctl - agent control commands
|
||||||
|
//
|
||||||
|
// # Blocking Reads
|
||||||
|
//
|
||||||
|
// Files like statewait and eventwait block until their condition is met.
|
||||||
|
// This is the primary coordination mechanism — clients read these files
|
||||||
|
// in a loop to react to state changes.
|
||||||
|
//
|
||||||
|
// # Implementation
|
||||||
|
//
|
||||||
|
// The spec is pure declaration; handlers are closures that capture their
|
||||||
|
// targets. NewRoot() builds the virtfs.Tree from the spec. The tree is
|
||||||
|
// served directly by the 9P server.
|
||||||
|
package fs
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
// Package session owns session lifecycle, persistence, and agent management.
|
||||||
|
//
|
||||||
|
// A Session groups agents, manages the tool server connection, and provides
|
||||||
|
// the context for cancellation propagation. Sessions can be paused (releasing
|
||||||
|
// resources) and resumed.
|
||||||
|
//
|
||||||
|
// # Lifecycle
|
||||||
|
//
|
||||||
|
// Sessions are created via the registry (NewSession or restored from disk).
|
||||||
|
// Each session owns:
|
||||||
|
//
|
||||||
|
// - A unique ID and optional friendly name
|
||||||
|
// - A list of agents
|
||||||
|
// - A tool server process and connection (local or remote)
|
||||||
|
// - A goal (session-level objective) that can trigger workflows
|
||||||
|
// - Autosave state for persistence
|
||||||
|
//
|
||||||
|
// # Persistence
|
||||||
|
//
|
||||||
|
// Sessions are saved to JSON files under the sessions directory. Save
|
||||||
|
// happens automatically on state changes (debounced). Restore rebuilds
|
||||||
|
// agents from saved history and reconnects to tool servers.
|
||||||
|
//
|
||||||
|
// # Tool Server
|
||||||
|
//
|
||||||
|
// Each session owns a toolsrv process. The session manages spawning,
|
||||||
|
// connection, and the bypass approval loop. On pause, the tool server
|
||||||
|
// is stopped; on resume, a new one is spawned.
|
||||||
|
//
|
||||||
|
// # Events
|
||||||
|
//
|
||||||
|
// The package publishes session events (pause, resume, agent changes)
|
||||||
|
// through an internal event bus. Use SubscribeWait/UnsubscribeWait for
|
||||||
|
// blocking waits on specific events.
|
||||||
|
package session
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
// Package toolclient manages the toolsrv connection from olliesrv.
|
||||||
|
//
|
||||||
|
// It handles process spawning (local or remote via SSH), connection
|
||||||
|
// lifecycle, and the 9P client for tool operations.
|
||||||
|
//
|
||||||
|
// # Process Management
|
||||||
|
//
|
||||||
|
// ProcessKeeper maintains a running toolsrv process, respawning it if
|
||||||
|
// it dies. It provides stable connection dialing even across restarts.
|
||||||
|
//
|
||||||
|
// For remote execution, toolsrv is deployed over SSH with socket
|
||||||
|
// forwarding. The local olliesrv talks to the remote toolsrv as if
|
||||||
|
// it were local.
|
||||||
|
//
|
||||||
|
// # Connection
|
||||||
|
//
|
||||||
|
// ToolsrvConn wraps a 9P connection to toolsrv. It provides typed
|
||||||
|
// methods for common operations:
|
||||||
|
//
|
||||||
|
// - ListTools, LoadTool, UnloadTool — tool registry
|
||||||
|
// - CallTool, CallToolBackground — tool execution
|
||||||
|
// - SignalDetached, GetDetachedOutput — background process management
|
||||||
|
// - ReadBypassPending, ResolveBypass — sandbox bypass
|
||||||
|
//
|
||||||
|
// # Authentication
|
||||||
|
//
|
||||||
|
// Connections are authenticated with a shared secret established on
|
||||||
|
// first connection. Subsequent connections (e.g., after respawn) must
|
||||||
|
// provide the same secret.
|
||||||
|
package toolclient
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
// Package registry manages per-agent tool registries within a toolsrv session.
|
||||||
|
//
|
||||||
|
// Each agent in a session has its own set of loaded tools. The registry
|
||||||
|
// tracks which tools are loaded for each agent and provides metadata for
|
||||||
|
// prompt generation.
|
||||||
|
//
|
||||||
|
// # Per-Agent Scoping
|
||||||
|
//
|
||||||
|
// Tools are scoped to agents via their agent ID. Operations:
|
||||||
|
//
|
||||||
|
// - LoadTool(agentID, name): add tool to agent's set
|
||||||
|
// - UnloadTool(agentID, name): remove tool from agent's set
|
||||||
|
// - ClearTools(agentID): remove all tools for an agent
|
||||||
|
// - GetTools(agentID): list loaded tools with metadata
|
||||||
|
//
|
||||||
|
// # Metadata
|
||||||
|
//
|
||||||
|
// Tool metadata comes from .meta files in the tools directory. Metadata
|
||||||
|
// includes name, description, input schema, scope (read/write/global),
|
||||||
|
// timeout, and output format.
|
||||||
|
//
|
||||||
|
// # Revision Tracking
|
||||||
|
//
|
||||||
|
// Each agent's tool set has a revision number that increments on any
|
||||||
|
// change. Clients can poll this to detect when to refresh their tool
|
||||||
|
// list.
|
||||||
|
package registry
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
// Package sandbox provides Landlock-based filesystem sandboxing for tool execution.
|
||||||
|
//
|
||||||
|
// Tools run in a restricted environment where filesystem access is limited
|
||||||
|
// to explicitly allowed paths. The sandbox is configured via sandbox.yaml
|
||||||
|
// and enforced using Linux Landlock.
|
||||||
|
//
|
||||||
|
// # Configuration
|
||||||
|
//
|
||||||
|
// sandbox.yaml defines named profiles with path rules:
|
||||||
|
//
|
||||||
|
// default:
|
||||||
|
// ro:
|
||||||
|
// - /usr
|
||||||
|
// - /lib
|
||||||
|
// rw:
|
||||||
|
// - ${cwd}
|
||||||
|
// - /tmp
|
||||||
|
//
|
||||||
|
// Environment variables in paths are expanded at runtime. The special
|
||||||
|
// variable ${cwd} refers to the agent's working directory.
|
||||||
|
//
|
||||||
|
// # Enforcement
|
||||||
|
//
|
||||||
|
// On Linux, a native helper binary applies Landlock restrictions before
|
||||||
|
// exec'ing the tool. The helper receives the sandbox profile via base64-
|
||||||
|
// encoded JSON on the command line.
|
||||||
|
//
|
||||||
|
// On non-Linux platforms, sandboxing is a no-op (the tool runs unrestricted).
|
||||||
|
//
|
||||||
|
// # Bypass
|
||||||
|
//
|
||||||
|
// Tools can request bypass for operations outside the sandbox. These
|
||||||
|
// requests are routed to the bypass broker in olliesrv for policy
|
||||||
|
// evaluation and optional user approval.
|
||||||
|
package sandbox
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
// Package server provides the 9P namespace and process state for toolsrv.
|
||||||
|
//
|
||||||
|
// The namespace is declared using virtfs and exposes tool execution,
|
||||||
|
// process management, and bypass request handling.
|
||||||
|
//
|
||||||
|
// # Namespace Structure
|
||||||
|
//
|
||||||
|
// /ctl - control commands (load, unload, clear)
|
||||||
|
// /tools - tool listing (rdwr: write agent ID, read JSON)
|
||||||
|
// /all - all available tools on disk
|
||||||
|
// /info - host info (platform, arch)
|
||||||
|
// /bypass/ - bypass request handling
|
||||||
|
// /proc/ - process management
|
||||||
|
// /proc/new - execute tool (blocking rdwr)
|
||||||
|
// /proc/new.bg - execute in background (returns PID)
|
||||||
|
// /proc/{pid}/ - per-process files (out, wait, stat, ctl)
|
||||||
|
//
|
||||||
|
// # Process Execution
|
||||||
|
//
|
||||||
|
// Tools are executed via /proc/new (foreground) or /proc/new.bg (background).
|
||||||
|
// The request includes token, tool name, and arguments. Foreground calls
|
||||||
|
// block until completion; background calls return immediately with a PID.
|
||||||
|
//
|
||||||
|
// # Path Locking
|
||||||
|
//
|
||||||
|
// Write-scope tools acquire path-based locks to prevent concurrent writes
|
||||||
|
// to the same file. Global-scope tools acquire a global lock that serializes
|
||||||
|
// with everything. Read-scope tools never lock.
|
||||||
|
package server
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
// Package log provides structured logging for ollie.
|
||||||
|
//
|
||||||
|
// The package supports log levels (debug, info, warn, error) and can
|
||||||
|
// be configured via environment variables or programmatically.
|
||||||
|
//
|
||||||
|
// # Usage
|
||||||
|
//
|
||||||
|
// log := log.New("session")
|
||||||
|
// log.Info("starting session %s", id)
|
||||||
|
// log.Debug("verbose detail: %v", data)
|
||||||
|
//
|
||||||
|
// # Configuration
|
||||||
|
//
|
||||||
|
// Set OLLIE_LOG_LEVEL to control output: debug, info, warn, error.
|
||||||
|
// Default is info. Debug output is useful for development but verbose
|
||||||
|
// in production.
|
||||||
|
//
|
||||||
|
// # Output Format
|
||||||
|
//
|
||||||
|
// Messages include timestamp, level, logger name, and message:
|
||||||
|
//
|
||||||
|
// 2024-01-15T10:30:00 INFO [session] starting session abc123
|
||||||
|
package log
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Package skills provides skill discovery and semantic matching.
|
||||||
|
//
|
||||||
|
// Skills are markdown knowledge modules with YAML frontmatter. They inject
|
||||||
|
// domain expertise into the agent's context when semantically relevant to
|
||||||
|
// the user's request.
|
||||||
|
//
|
||||||
|
// # Skill Format
|
||||||
|
//
|
||||||
|
// Skills are markdown files with YAML frontmatter:
|
||||||
|
//
|
||||||
|
// ---
|
||||||
|
// name: git
|
||||||
|
// description: Git version control operations
|
||||||
|
// tags: [vcs, commit, branch]
|
||||||
|
// ---
|
||||||
|
//
|
||||||
|
// # Git Skill
|
||||||
|
//
|
||||||
|
// Common operations...
|
||||||
|
//
|
||||||
|
// # Discovery
|
||||||
|
//
|
||||||
|
// LoadSkills scans the skills directory ($XDG_CONFIG_HOME/ollie/skills)
|
||||||
|
// and parses skill metadata. Skills are indexed by name for lookup and
|
||||||
|
// by description for semantic matching.
|
||||||
|
//
|
||||||
|
// # Matching
|
||||||
|
//
|
||||||
|
// MatchSkills uses embedding similarity to find skills relevant to a
|
||||||
|
// query. The top matches (up to a configured limit) are injected into
|
||||||
|
// the agent's context. This enables capability discovery without
|
||||||
|
// requiring exact skill names.
|
||||||
|
package skills
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Package metadata provides tool discovery and metadata loading for toolsrv.
|
||||||
|
//
|
||||||
|
// Tools are external executables with associated .meta JSON files that
|
||||||
|
// describe their interface. This package discovers tools on disk and
|
||||||
|
// parses their metadata.
|
||||||
|
//
|
||||||
|
// # Discovery
|
||||||
|
//
|
||||||
|
// DiscoverTools scans the tools directory ($XDG_CONFIG_HOME/ollie/tools)
|
||||||
|
// for executable files with matching .meta sidecar files. Both compiled
|
||||||
|
// Go tools and script tools use the same discovery mechanism.
|
||||||
|
//
|
||||||
|
// # Metadata Format
|
||||||
|
//
|
||||||
|
// The .meta file is JSON:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "description": "Short description for prompts",
|
||||||
|
// "prompt": "## tool_name\n\nDetailed usage...",
|
||||||
|
// "args": { "path": {"type": "string", "description": "..."} },
|
||||||
|
// "tier": "hot",
|
||||||
|
// "scope": "write",
|
||||||
|
// "timeout": 30
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Fields:
|
||||||
|
// - description: one-line summary for tool listing
|
||||||
|
// - prompt: full documentation injected into system prompt
|
||||||
|
// - args: JSON Schema for tool arguments
|
||||||
|
// - tier: hot (always injected), warm, cold (on-demand)
|
||||||
|
// - scope: read, write, global (for conflict detection)
|
||||||
|
// - timeout: default execution timeout in seconds
|
||||||
|
package metadata
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
// Package protocol defines shared types and wire formats for toolsrv communication.
|
||||||
|
//
|
||||||
|
// These types are used by both olliesrv (toolclient) and toolsrv to ensure
|
||||||
|
// consistent serialization of tool metadata, results, and requests.
|
||||||
|
//
|
||||||
|
// # Types
|
||||||
|
//
|
||||||
|
// - ToolInfo: tool metadata sent from toolsrv to olliesrv
|
||||||
|
// - ToolResult: structured result from tool execution
|
||||||
|
// - ToolResultContent: single content item (text or image)
|
||||||
|
// - BypassRequest: sandbox bypass request
|
||||||
|
//
|
||||||
|
// # Payload Format
|
||||||
|
//
|
||||||
|
// Tool execution uses a key=value payload format over 9P:
|
||||||
|
//
|
||||||
|
// token=<session-token>
|
||||||
|
// tool=<name>
|
||||||
|
// agent=<agent-id>
|
||||||
|
// path=/some/path
|
||||||
|
// content=escaped\nvalue
|
||||||
|
//
|
||||||
|
// Values are escaped: \n for newlines, \\ for backslashes. ParsePayload
|
||||||
|
// and escaping helpers handle the conversion.
|
||||||
|
package protocol
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Package util provides common utilities for ollie.
|
||||||
|
//
|
||||||
|
// # Path Helpers
|
||||||
|
//
|
||||||
|
// - CfgDir: returns $XDG_CONFIG_HOME/ollie
|
||||||
|
// - DataDir: returns $XDG_DATA_HOME/ollie
|
||||||
|
// - RuntimeDir: returns $XDG_RUNTIME_DIR or /run/user/UID
|
||||||
|
// - ExpandHome: expands ~ to home directory
|
||||||
|
// - IsGitRepo: checks if directory contains .git
|
||||||
|
//
|
||||||
|
// # UUID Generation
|
||||||
|
//
|
||||||
|
// - NewUUID: generates a random UUIDv4 string
|
||||||
|
//
|
||||||
|
// # Environment
|
||||||
|
//
|
||||||
|
// The package follows XDG Base Directory Specification for locating
|
||||||
|
// configuration and data files. Environment variables take precedence;
|
||||||
|
// standard fallbacks are used when unset.
|
||||||
|
package util
|
||||||
Loading…
Reference in New Issue