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:
parent
28085e4de0
commit
7055444748
|
|
@ -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 |
|
||||
|------|-------|
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue