From 7055444748088b153eb10eb2f7dbf88977f1ead3 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 19 Aug 2026 17:22:30 +0200 Subject: [PATCH] agent peers: bidirectional peer links with topology-controlled messaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 3 +- README.md | 3 +- cmd/olliesrv/internal/agent/agent.go | 31 ++++++++++ cmd/olliesrv/internal/fs/spec.go | 59 ++++++++++++++++++- .../internal/prompts/system_prompt.md | 26 +++++++- cmd/olliesrv/internal/session/persist.go | 16 +++++ doc/architecture-9p.md | 17 ++++++ doc/architecture-core.md | 1 + doc/architecture.md | 2 +- doc/usage.md | 3 + 10 files changed, 156 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1cced7c..257d2eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | |------|-------| diff --git a/README.md b/README.md index dafd045..1441d24 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/olliesrv/internal/agent/agent.go b/cmd/olliesrv/internal/agent/agent.go index b1a8ed7..c23d717 100644 --- a/cmd/olliesrv/internal/agent/agent.go +++ b/cmd/olliesrv/internal/agent/agent.go @@ -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 diff --git a/cmd/olliesrv/internal/fs/spec.go b/cmd/olliesrv/internal/fs/spec.go index be3e169..817d90d 100644 --- a/cmd/olliesrv/internal/fs/spec.go +++ b/cmd/olliesrv/internal/fs/spec.go @@ -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) }), diff --git a/cmd/olliesrv/internal/prompts/system_prompt.md b/cmd/olliesrv/internal/prompts/system_prompt.md index 12f8e16..83d9f1b 100644 --- a/cmd/olliesrv/internal/prompts/system_prompt.md +++ b/cmd/olliesrv/internal/prompts/system_prompt.md @@ -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. diff --git a/cmd/olliesrv/internal/session/persist.go b/cmd/olliesrv/internal/session/persist.go index ff0bca4..e055bcd 100644 --- a/cmd/olliesrv/internal/session/persist.go +++ b/cmd/olliesrv/internal/session/persist.go @@ -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) diff --git a/doc/architecture-9p.md b/doc/architecture-9p.md index 57416ad..026f3d4 100644 --- a/doc/architecture-9p.md +++ b/doc/architecture-9p.md @@ -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 ` / `proc kill ` | Stop a background process. | | `proc out ` | Read process output. | | `proc dismiss ` | Dismiss a completed process. | +| `peeradd ` | Add a bidirectional peer link to the named agent. | +| `peerdel ` | Remove a bidirectional peer link. | +| `peers` | List current peers. | | `systemprompt` | Read the rendered system prompt. | | `help` | List controls. | diff --git a/doc/architecture-core.md b/doc/architecture-core.md index 0567667..d2426f5 100644 --- a/doc/architecture-core.md +++ b/doc/architecture-core.md @@ -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 diff --git a/doc/architecture.md b/doc/architecture.md index 8b09d03..3f92fec 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -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. diff --git a/doc/usage.md b/doc/usage.md index 5453370..95d42ba 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -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: