Add reply file for agent chaining
reply holds only the clean assistant text from the most recently completed turn. It is written atomically when Submit returns and cleared (as a documented side-effect) when a new prompt is submitted. Stat reports the real byte length and bumps Qid.Vers on each change, so polling via stat size > 0 is the intended signal that a reply is ready. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c3bddf5942
commit
2c41e68696
|
|
@ -8,8 +8,9 @@ A 9P server that exposes [ollie](../ollie) agent sessions as a virtual filesyste
|
||||||
ollie/
|
ollie/
|
||||||
ctl write: "new [backend=x] [model=x] [agent=x]" | "kill <session-id>"
|
ctl write: "new [backend=x] [model=x] [agent=x]" | "kill <session-id>"
|
||||||
<session-id>/
|
<session-id>/
|
||||||
prompt write: submit a prompt to the agent
|
prompt write: submit a prompt to the agent (clears reply)
|
||||||
chat read: cumulative conversation history
|
chat read: cumulative conversation history
|
||||||
|
reply read: assistant text from the most recent turn only
|
||||||
state read: current agent state (idle, thinking, calling: <tool>)
|
state read: current agent state (idle, thinking, calling: <tool>)
|
||||||
ctl write: stop | interrupt | /<slash-command>
|
ctl write: stop | interrupt | /<slash-command>
|
||||||
backend r/w: active backend name
|
backend r/w: active backend name
|
||||||
|
|
@ -122,7 +123,7 @@ echo "kill <session-id>" > ~/mnt/ollie/ctl
|
||||||
|
|
||||||
**Remote access** — 9P is a network protocol. Export the namespace over the network and access agent sessions from another machine using the same file interface, with no additional daemon or API layer.
|
**Remote access** — 9P is a network protocol. Export the namespace over the network and access agent sessions from another machine using the same file interface, with no additional daemon or API layer.
|
||||||
|
|
||||||
**Agent chaining** — one agent's `chat` output piped into another session's `prompt`. Model composition at the shell level.
|
**Agent chaining** — pipe one agent's `reply` into another session's `prompt`. Poll until `reply` is non-empty (stat size > 0), consume it, submit to the next agent. Writing to `prompt` clears `reply` as a side-effect, so the handoff is clean.
|
||||||
|
|
||||||
**Hooks and watchers** — poll `state` in a loop to trigger actions when the agent finishes, or use `tail -f chat` to react to new output. Build lightweight event-driven pipelines with standard shell tools.
|
**Hooks and watchers** — poll `state` in a loop to trigger actions when the agent finishes, or use `tail -f chat` to react to new output. Build lightweight event-driven pipelines with standard shell tools.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@ type session struct {
|
||||||
// state is the current agent state: "idle", "thinking", "calling: <tool>"
|
// state is the current agent state: "idle", "thinking", "calling: <tool>"
|
||||||
state string
|
state string
|
||||||
modelName string
|
modelName string
|
||||||
|
// reply holds only the assistant text from the most recently completed
|
||||||
|
// turn. Cleared when a new prompt is submitted. Read-only; 0444.
|
||||||
|
reply []byte
|
||||||
|
replyVers uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sess *session) setState(s string) {
|
func (sess *session) setState(s string) {
|
||||||
|
|
@ -204,7 +208,7 @@ func (s *Server) pathType(path string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
switch parts[1] {
|
switch parts[1] {
|
||||||
case "ctl", "prompt", "chat", "backend", "agent", "model", "state":
|
case "ctl", "prompt", "chat", "reply", "backend", "agent", "model", "state":
|
||||||
return "file"
|
return "file"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -327,9 +331,12 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
||||||
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
|
||||||
}
|
}
|
||||||
|
|
||||||
// chat is served directly from the session's append-only log.
|
// chat and reply are served directly from session buffers by offset.
|
||||||
if pathBase(path) == "chat" {
|
switch pathBase(path) {
|
||||||
|
case "chat":
|
||||||
return s.readChat(fc, path)
|
return s.readChat(fc, path)
|
||||||
|
case "reply":
|
||||||
|
return s.readReply(fc, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
content := s.readFile(path)
|
content := s.readFile(path)
|
||||||
|
|
@ -376,6 +383,35 @@ func (s *Server) readChat(fc *plan9.Fcall, path string) *plan9.Fcall {
|
||||||
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readReply serves bytes from the session's reply buffer at the requested offset.
|
||||||
|
func (s *Server) readReply(fc *plan9.Fcall, path string) *plan9.Fcall {
|
||||||
|
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
|
||||||
|
}
|
||||||
|
s.mu.RLock()
|
||||||
|
sess := s.sessions[parts[0]]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if sess == nil {
|
||||||
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
sess.mu.RLock()
|
||||||
|
buf := sess.reply
|
||||||
|
sess.mu.RUnlock()
|
||||||
|
|
||||||
|
var data []byte
|
||||||
|
off := int(fc.Offset)
|
||||||
|
if off < len(buf) {
|
||||||
|
end := off + int(fc.Count)
|
||||||
|
if end > len(buf) {
|
||||||
|
end = len(buf)
|
||||||
|
}
|
||||||
|
data = buf[off:end]
|
||||||
|
}
|
||||||
|
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
|
||||||
|
}
|
||||||
|
|
||||||
// readFile returns the text content for a readable session file.
|
// readFile returns the text content for a readable session file.
|
||||||
func (s *Server) readFile(path string) string {
|
func (s *Server) readFile(path string) string {
|
||||||
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2)
|
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2)
|
||||||
|
|
@ -507,8 +543,24 @@ func (s *Server) handleWrite(path, input string) {
|
||||||
switch fileName {
|
switch fileName {
|
||||||
case "prompt":
|
case "prompt":
|
||||||
sess.appendChat([]byte("user: " + input + "\n"))
|
sess.appendChat([]byte("user: " + input + "\n"))
|
||||||
|
// Clear reply before submission — documented side-effect.
|
||||||
|
sess.mu.Lock()
|
||||||
|
sess.reply = nil
|
||||||
|
sess.replyVers++
|
||||||
|
sess.mu.Unlock()
|
||||||
sess.setState("thinking")
|
sess.setState("thinking")
|
||||||
sess.core.Submit(sess.ctx, input, publish)
|
var replyBuf []byte
|
||||||
|
sess.core.Submit(sess.ctx, input, func(ev agent.Event) {
|
||||||
|
if ev.Role == "assistant" {
|
||||||
|
replyBuf = append(replyBuf, []byte(ev.Content)...)
|
||||||
|
}
|
||||||
|
publish(ev)
|
||||||
|
})
|
||||||
|
// Atomically publish reply on turn completion.
|
||||||
|
sess.mu.Lock()
|
||||||
|
sess.reply = replyBuf
|
||||||
|
sess.replyVers++
|
||||||
|
sess.mu.Unlock()
|
||||||
sess.setState("idle")
|
sess.setState("idle")
|
||||||
|
|
||||||
case "ctl":
|
case "ctl":
|
||||||
|
|
@ -786,6 +838,7 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
|
||||||
{"ctl", 0200},
|
{"ctl", 0200},
|
||||||
{"prompt", 0200},
|
{"prompt", 0200},
|
||||||
{"chat", 0444},
|
{"chat", 0444},
|
||||||
|
{"reply", 0444},
|
||||||
{"state", 0444},
|
{"state", 0444},
|
||||||
{"backend", 0666},
|
{"backend", 0666},
|
||||||
{"agent", 0666},
|
{"agent", 0666},
|
||||||
|
|
@ -848,7 +901,7 @@ func (s *Server) makeStat(path string) plan9.Dir {
|
||||||
switch base {
|
switch base {
|
||||||
case "ctl", "prompt":
|
case "ctl", "prompt":
|
||||||
mode = 0200
|
mode = 0200
|
||||||
case "chat", "state":
|
case "chat", "reply", "state":
|
||||||
mode = 0444
|
mode = 0444
|
||||||
case "backend", "agent", "model":
|
case "backend", "agent", "model":
|
||||||
mode = 0666
|
mode = 0666
|
||||||
|
|
@ -868,7 +921,7 @@ func (s *Server) makeStat(path string) plan9.Dir {
|
||||||
|
|
||||||
// For chat files, report the actual log size and Qid version so that
|
// For chat files, report the actual log size and Qid version so that
|
||||||
// polling tools (tail -f) can detect growth via stat.
|
// polling tools (tail -f) can detect growth via stat.
|
||||||
if base == "chat" {
|
if base == "chat" || base == "reply" {
|
||||||
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2)
|
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2)
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
|
|
@ -876,8 +929,13 @@ func (s *Server) makeStat(path string) plan9.Dir {
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
if sess != nil {
|
if sess != nil {
|
||||||
sess.mu.RLock()
|
sess.mu.RLock()
|
||||||
dir.Length = uint64(len(sess.chatLog))
|
if base == "chat" {
|
||||||
dir.Qid.Vers = sess.chatVers
|
dir.Length = uint64(len(sess.chatLog))
|
||||||
|
dir.Qid.Vers = sess.chatVers
|
||||||
|
} else {
|
||||||
|
dir.Length = uint64(len(sess.reply))
|
||||||
|
dir.Qid.Vers = sess.replyVers
|
||||||
|
}
|
||||||
sess.mu.RUnlock()
|
sess.mu.RUnlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Reference in New Issue