agent peers: bidirectional peer links with topology-controlled messaging

Add peer/ directory to each agent's 9P namespace. Agents communicate
by writing to peer/{name}, which delivers to the target's prompt handler.
Only declared peers can be messaged — the directory is the ACL.

Implementation:
- Agent struct: peers map + AddPeer/RemovePeer/Peers methods
- fs/spec.go: peer/ Each node (write-only entries), peeradd/peerdel/peers ctl commands
- Bidirectional: peeradd A on B also adds B on A
- Peers constrained to same session
- Persisted with session state (PersistedAgent.Peers field)
- peeradd/peerdel trigger immediate session save

Docs updated: system_prompt.md, AGENTS.md, README.md, architecture-9p.md,
architecture-core.md, architecture.md, usage.md.
This commit is contained in:
Levi Neely 2026-08-19 17:22:30 +02:00
parent 28085e4de0
commit 7055444748
10 changed files with 156 additions and 5 deletions

View File

@ -102,7 +102,8 @@ For direct Go testing, use the packages covered by `make test-core` and `make te
8. **Prompt assembly**: `cmd/olliesrv/internal/prompts/system_prompt.md` is embedded as the default system prompt. An agent's `systemPrompt` can override it with a filesystem path. `prompt` and `userPrompts` entries resolve files, expand environment variables, and support legacy `!command` entries. The runtime combines system, environment, agent, and tool sections.
9. **Context management**: History tracks messages, usage, costs, cache statistics, and structured task state. Cold/warm/hot result tiers and automatic compaction preserve recent context while summarizing older material.
10. **Sub-agents**: `subagent_spawn` creates a transient child session with an independent runtime and context. The child receives a one-time parent-history snapshot and returns only its final reply. Parent/child IDs are retained for tracing; concurrent children are supported.
11. **9P namespace declaration**: `cmd/olliesrv/internal/fs/spec.go` declares the olliesrv namespace. The toolsrv namespace is declared by `cmd/toolsrv/p9.go` using `cmd/toolsrv/internal/server.Spec`; process state and handlers are in `cmd/toolsrv/internal/server/`. Both use the `virtfs` EDSL and `virtfs.BuildTree()`.
11. **Peers**: Persistent agents in the same session can be linked via `peeradd`. Links are bidirectional. Agents communicate by writing to `peer/{name}`, which delivers to the target's prompt handler. Only declared peers can be messaged — the `peer/` directory is the access control surface.
12. **9P namespace declaration**: `cmd/olliesrv/internal/fs/spec.go` declares the olliesrv namespace. The toolsrv namespace is declared by `cmd/toolsrv/p9.go` using `cmd/toolsrv/internal/server.Spec`; process state and handlers are in `cmd/toolsrv/internal/server/`. Both use the `virtfs` EDSL and `virtfs.BuildTree()`.
## Key Files
| What | Where |
|------|-------|

View File

@ -48,7 +48,7 @@ flowchart TB
- `olliesrv` owns the in-memory 9P tree and session collection.
- Each session owns one or more agents. Agents maintain configuration, state, prompts, chat history, rendered context, usage, and cost.
- The agent loop sends context to the configured provider, dispatches tool calls, updates history, and repeats until completion or cancellation.
- Child agents are ordinary agents created through the session namespace and communicate through prompt/result files.
- Child agents are ordinary agents created through the session namespace and communicate through prompt/result files. Peer links (`peer/` directory) provide topology-controlled inter-agent messaging within a session.
The 9P namespace is the API:
@ -71,6 +71,7 @@ session/
├── state current state
├── statewait block until state changes
├── prompt submit a prompt
├── peer/ write to peer/{name} to message a peer agent
├── chat conversation history
├── context rendered model context
├── systemprompt rendered system prompt

View File

@ -46,6 +46,8 @@ type Agent struct {
parentID string // immutable ID of the agent that spawned this agent
depth int // sub-agent depth (0=top-level, 1=sub-agent, 2=sub-sub-agent)
activeChildren atomic.Int32 // number of currently-running child sub-agents
peers map[string]struct{} // peer agent names (same session)
peerMu sync.RWMutex
fifo Fifo // prompt queue
Feed Feed // streaming input gate (change-detecting)
toolCallCount atomic.Int64
@ -154,6 +156,35 @@ 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

View File

@ -794,6 +794,32 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
return nil
}),
),
virtfs.Each("peer", func() ([]virtfs.FsNodeDecl, error) {
var out []virtfs.FsNodeDecl
for _, peerName := range a.Peers() {
name := peerName
out = append(out, virtfs.FsNodeDecl{
Name: name,
Mode: 0222,
Write: func(data []byte) error {
target := s.FindAgent(name)
if target == nil {
return fmt.Errorf("peer %q not found", name)
}
input := strings.TrimSpace(string(data))
if input == "" {
return nil
}
startAsync(s.Ctx, func() {
target.Submit(s.Ctx, input)
target.EnsureTrailingNewline()
})
return nil
},
})
}
return out, nil
}),
virtfs.FileNode("ctl", 0666,
virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
return dispatch(map[string]func([]string) ([]byte, error){
@ -1030,9 +1056,40 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
"systemprompt": func(_ []string) ([]byte, error) {
return []byte(a.SystemPrompt() + "\n"), nil
},
"peeradd": func(args []string) ([]byte, error) {
if len(args) == 0 {
return nil, fmt.Errorf("peeradd requires agent name")
}
target := s.FindAgent(args[0])
if target == nil {
return nil, fmt.Errorf("agent %q not found in session", args[0])
}
a.AddPeer(target.Name())
target.AddPeer(a.Name())
go s.Save()
return []byte("ok\n"), nil
},
"peerdel": func(args []string) ([]byte, error) {
if len(args) == 0 {
return nil, fmt.Errorf("peerdel requires agent name")
}
a.RemovePeer(args[0])
if target := s.FindAgent(args[0]); target != nil {
target.RemovePeer(a.Name())
}
go s.Save()
return []byte("ok\n"), nil
},
"peers": func(_ []string) ([]byte, error) {
peers := a.Peers()
if len(peers) == 0 {
return []byte("(no peers)\n"), nil
}
return []byte(strings.Join(peers, "\n") + "\n"), nil
},
"stats": stats,
"help": func(_ []string) ([]byte, error) {
return []byte("stop compact clear inject agent model models tools tool_load tool_unload cwd proc name backend systemprompt stats help\n"), nil
return []byte("stop compact clear inject agent model models tools tool_load tool_unload cwd proc name backend systemprompt peeradd peerdel peers stats help\n"), nil
},
}, data)
}),

View File

@ -162,7 +162,8 @@ Your world model is a 9P filesystem. Your session ID is `${OLLIE_SESSION_ID}`. U
| `chat.raw` | read | Full conversation with block markers |
| `statewait` | read | Blocks until state changes; returns new value |
| `cfg` | r/w | Agent config (key=value: backend, model, cwd, temperature, etc.) |
| `ctl` | rdwr | Control: stop, compact, clear, inject, agent, model, tools, tools_all, tool_load, tool_unload, cwd, name, proc |
| `ctl` | rdwr | Control: stop, compact, clear, inject, agent, model, tools, tool_load, tool_unload, cwd, name, peeradd, peerdel, peers, proc |
| `peer/` | dir | Peer agent links. Write to `peer/{name}` to send a message to that peer. |
| `stats` | read | Usage, cost, context size |
## Operations
@ -221,3 +222,26 @@ printf 'cwd=%s\nprompt=fix tests in auth/\n' "$PWD" \
printf 'cwd=%s\nprompt=fix tests in api/\n' "$PWD" \
| ollie-9p rdwr session/$OLLIE_SESSION_ID/agent/new
```
## Peers
Peer agents are persistent agents in the same session that can communicate directly. Unlike sub-agents, peers retain their own context across interactions and are not destroyed after a single task.
The `peer/` directory lists agents you can message. You can only write to agents that are your peers — this is the access control mechanism.
```bash
# Send a message to a peer
echo "I've finished my analysis. Here are my findings: ..." \
| ollie-9p write session/$OLLIE_SESSION_ID/agent/$OLLIE_UNAME/peer/{peername}
# List your peers
echo "peers" | ollie-9p rdwr session/$OLLIE_SESSION_ID/agent/$OLLIE_UNAME/ctl
# Add a peer (bidirectional — both agents get the link)
echo "peeradd othername" | ollie-9p rdwr session/$OLLIE_SESSION_ID/agent/$OLLIE_UNAME/ctl
# Remove a peer (bidirectional)
echo "peerdel othername" | ollie-9p rdwr session/$OLLIE_SESSION_ID/agent/$OLLIE_UNAME/ctl
```
When you receive a message from a peer, it arrives as a prompt. Respond by writing to your peer link for that agent. Peers are constrained to the current session — cross-session communication is not supported.

View File

@ -37,6 +37,7 @@ type PersistedAgent struct {
Profile string `json:"profile"`
Backend string `json:"backend"`
Model string `json:"model"`
Peers []string `json:"peers,omitempty"`
Messages []backend.Message `json:"messages"`
// Usage tracking
TotalInputTokens int `json:"totalInputTokens,omitempty"`
@ -86,6 +87,7 @@ func PersistSession(name string) error {
Profile: ag.Profile(),
Backend: ag.BackendName(),
Model: ag.ModelName(),
Peers: ag.Peers(),
Messages: backend.SanitizeMessages(ag.Messages()),
}
if usage := ag.Usage(); usage != nil {
@ -271,6 +273,20 @@ func restoreMultiAgentSession(ps *PersistedSession) (*RestoredSession, error) {
return nil, fmt.Errorf("no agents restored")
}
// Restore peer links (each agent's peers were persisted independently).
for _, pa := range ps.Agents {
if len(pa.Peers) == 0 {
continue
}
ag := sess.FindAgent(pa.ID)
if ag == nil {
continue
}
for _, peerName := range pa.Peers {
ag.AddPeer(peerName)
}
}
sess.Uname = sess.Agents()[0].ID()
Register(sess.Name(), sess)

View File

@ -90,6 +90,8 @@ echo "name=coding cwd=$PWD" | ollie-9p write session/myproj/agent/new
| `agent/{aname}/plan` | r/w | Persistent planning scratch space. |
| `agent/{aname}/cfg` | r/w | Agent configuration. |
| `agent/{aname}/ctl` | rdwr | Agent controls and queries. |
| `agent/{aname}/peer/` | dir | Peer agent links (write-only entries). |
| `agent/{aname}/peer/{name}` | w | Send a message to the named peer agent. |
| `agent/{aname}/stats` | r | Token, cost, and context statistics. |
| `agent/{aname}/name` | r/w | Mutable agent display name. |
| `agent/{aname}/id` | r | Immutable agent UUID. |
@ -106,6 +108,18 @@ ollie-9p read session/myproj/agent/coding/statewait
Sub-agents use the same namespace. The `agent/new` rdwr operation can create a child session and return its final response after the child exits.
### Peers
Agents in the same session can be linked as peers via `peeradd`. Peer links are bidirectional — adding A as a peer of B also adds B as a peer of A. Once linked, agents communicate by writing to `peer/{name}`, which delivers the message to the target agent's prompt handler. Agents can only message their declared peers, providing topology-level access control.
```sh
# Link two agents
echo "peeradd panelist_a" | ollie-9p rdwr session/myproj/agent/foreman/ctl
# Agent sends a message to its peer
echo "here are my findings" | ollie-9p write session/myproj/agent/panelist_a/peer/foreman
```
### Agent controls
| Command | Purpose |
@ -128,6 +142,9 @@ Sub-agents use the same namespace. The `agent/new` rdwr operation can create a c
| `proc term <pid>` / `proc kill <pid>` | Stop a background process. |
| `proc out <pid>` | Read process output. |
| `proc dismiss <pid>` | Dismiss a completed process. |
| `peeradd <name>` | Add a bidirectional peer link to the named agent. |
| `peerdel <name>` | Remove a bidirectional peer link. |
| `peers` | List current peers. |
| `systemprompt` | Read the rendered system prompt. |
| `help` | List controls. |

View File

@ -416,6 +416,7 @@ The agent emits events through its `EventHandler`. Session-level observers use `
- **Queue drain**: after each turn completes, queued prompts are drained sequentially
- **State observation**: agent state and chat changes signal 9P waiters through change channels and condition variables
- **Tool parallelism**: read-safe tools fan out within a turn when metadata permits; result caching and `/tmp/ollie/{sessionID}` locks coordinate execution
- **Peer messaging**: writing to `peer/{name}` triggers an async `Submit()` on the target agent; delivery is fire-and-forget within the session
## Session ID Format

View File

@ -39,7 +39,7 @@ Ollie deliberately does not build the following into the agent runtime:
- **Native MCP client support.** Use executable or metadata-only tools. An external bridge can invoke an MCP client when required.
- **Embedded tool frameworks.** Tools live outside the agent loop; toolsrv owns discovery, loading, execution, sandboxing, and process state. See [`architecture-tools.md`](architecture-tools.md) and [`architecture-toolsrv.md`](architecture-toolsrv.md).
- **Plan-and-execute workflow engines.** Ollie does not own planners, task graphs, schedulers, retries, compensation, or durable workflow state. A system such as [Beads](https://github.com/steveyegge/beads) can expose those capabilities through a tool.
- **External coordination protocols.** Ollie does not expose a workflow engine, actor framework, master coordinator, or public message bus. It does have an internal session event bus for observers. External coordination uses sessions, agents, `prompt`, `chat`, `statewait`, `feed`, and `ctl`.
- **External coordination protocols.** Ollie does not expose a workflow engine, actor framework, master coordinator, or public message bus. It does have an internal session event bus for observers. Inter-agent communication uses peer links (`peer/` directory) for topology-controlled messaging within a session. External coordination uses sessions, agents, `prompt`, `chat`, `statewait`, `feed`, and `ctl`.
- **Separate frontend control planes.** UIs, editor integrations, shell clients, and automation are 9P clients. They do not maintain a parallel session store or frontend-specific API. See [`architecture-9p.md`](architecture-9p.md).
- **Distributed agent state.** Remote execution moves toolsrv and tool execution, not the agent loop, prompts, history, or model calls. See [`architecture-remote.md`](architecture-remote.md).
- **A competing memory store.** [OptMem](https://github.com/VictorTaelin/OptMem) owns persistent memory; Ollie exposes it through tools.

View File

@ -154,6 +154,9 @@ and the rest is sent to `ctl`:
| `/tools` | List loaded tools |
| `/tool_load X` | Load a tool |
| `/tool_unload X` | Unload a tool |
| `/peeradd X` | Add a bidirectional peer link |
| `/peerdel X` | Remove a bidirectional peer link |
| `/peers` | List current peers |
| `!q` | Kill the tmux TUI session and exit |
Piped input is also supported: