377 lines
9.1 KiB
Go
377 lines
9.1 KiB
Go
package p9
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"ollie/pkg/agent"
|
|
"ollie/pkg/backend"
|
|
olog "ollie/pkg/log"
|
|
"ollie/pkg/store"
|
|
)
|
|
|
|
// sessionFileList defines the fixed set of files in a session directory,
|
|
// with their 9P permission modes.
|
|
var sessionFileList = []struct {
|
|
name string
|
|
mode os.FileMode
|
|
}{
|
|
{"ctl", 0200},
|
|
{"prompt", 0200},
|
|
{"fifo.in", 0200},
|
|
{"fifo.out", 0444},
|
|
{"chat", 0666},
|
|
{"offset", 0444},
|
|
{"state", 0444},
|
|
{"statewait", 0444},
|
|
{"backend", 0666},
|
|
{"agent", 0666},
|
|
{"model", 0666},
|
|
{"cwd", 0666},
|
|
{"cwdwait", 0444},
|
|
{"usage", 0444},
|
|
{"usagewait", 0444},
|
|
{"ctxsz", 0444},
|
|
{"ctxszwait", 0444},
|
|
{"models", 0444},
|
|
{"systemprompt", 0444},
|
|
{"params", 0666},
|
|
}
|
|
|
|
// SessionFileStore implements ReadWriteStore for the files within a single
|
|
// session directory (/s/{id}/*). The file set is fixed; Create, Delete, and
|
|
// Rename are not meaningful and are not part of the interface.
|
|
type SessionFileStore struct {
|
|
sess *session
|
|
log *olog.Logger
|
|
kill func()
|
|
rename func(newID string) error
|
|
saveTranscript func([]byte) error
|
|
}
|
|
|
|
func NewSessionFileStore(sess *session, log *olog.Logger, kill func(), rename func(newID string) error, saveTranscript func([]byte) error) *SessionFileStore {
|
|
return &SessionFileStore{sess: sess, log: log, kill: kill, rename: rename, saveTranscript: saveTranscript}
|
|
}
|
|
|
|
func (s *SessionFileStore) List() ([]os.DirEntry, error) {
|
|
entries := make([]os.DirEntry, len(sessionFileList))
|
|
for i, f := range sessionFileList {
|
|
entries[i] = syntheticEntry(f.name, f.mode)
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func (s *SessionFileStore) Stat(name string) (os.FileInfo, error) {
|
|
for _, f := range sessionFileList {
|
|
if f.name == name {
|
|
var size int64
|
|
switch name {
|
|
case "chat":
|
|
s.sess.mu.RLock()
|
|
size = int64(len(s.sess.chatLog))
|
|
s.sess.mu.RUnlock()
|
|
case "statewait", "usagewait", "ctxszwait", "cwdwait":
|
|
// Blocking reads; size is unknown until resolved.
|
|
default:
|
|
size = int64(len(s.content(name)))
|
|
}
|
|
return &syntheticFileInfo{Name_: name, Mode_: f.mode, Size_: size}, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("%s: not found", name)
|
|
}
|
|
|
|
func (s *SessionFileStore) Get(name string) ([]byte, error) {
|
|
switch name {
|
|
case "chat":
|
|
s.sess.mu.RLock()
|
|
data := make([]byte, len(s.sess.chatLog))
|
|
copy(data, s.sess.chatLog)
|
|
s.sess.mu.RUnlock()
|
|
return data, nil
|
|
case "offset":
|
|
return []byte(s.content("offset")), nil
|
|
case "fifo.out":
|
|
item, ok := s.sess.core.PopQueue()
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return []byte(item), nil
|
|
default:
|
|
for _, f := range sessionFileList {
|
|
if f.name == name {
|
|
return []byte(s.content(name)), nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("%s: not found", name)
|
|
}
|
|
}
|
|
|
|
func (s *SessionFileStore) Put(name string, data []byte) error {
|
|
input := strings.TrimSpace(string(data))
|
|
if input == "" {
|
|
return nil
|
|
}
|
|
switch name {
|
|
case "chat":
|
|
return s.saveTranscript([]byte(input))
|
|
|
|
case "prompt":
|
|
s.sess.core.Submit(s.sess.ctx, input, s.makePublish())
|
|
|
|
case "fifo.in":
|
|
s.sess.core.Queue(input)
|
|
|
|
case "ctl":
|
|
return s.handleCtl(input)
|
|
|
|
case "backend":
|
|
if s.sess.core.IsRunning() {
|
|
return fmt.Errorf("cannot switch backend while agent is running")
|
|
}
|
|
s.sess.core.Submit(s.sess.ctx, "/backend "+input, s.makePublish())
|
|
|
|
case "agent":
|
|
if s.sess.core.IsRunning() {
|
|
return fmt.Errorf("cannot switch agent while agent is running")
|
|
}
|
|
s.sess.core.Submit(s.sess.ctx, "/agent "+input, s.makePublish())
|
|
|
|
case "model":
|
|
if s.sess.core.IsRunning() {
|
|
return fmt.Errorf("cannot switch model while agent is running")
|
|
}
|
|
s.sess.core.Submit(s.sess.ctx, "/model "+input, s.makePublish())
|
|
|
|
case "cwd":
|
|
if err := s.sess.core.SetCWD(input); err != nil {
|
|
return err
|
|
}
|
|
|
|
case "params":
|
|
if s.sess.core.IsRunning() {
|
|
return fmt.Errorf("cannot change params while agent is running")
|
|
}
|
|
params, err := parseParams(input, s.sess.core.GenerationParams())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.sess.core.SetGenerationParams(params)
|
|
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func formatParams(p backend.GenerationParams) string {
|
|
var sb strings.Builder
|
|
fmt.Fprintf(&sb, "maxTokens=%d\n", p.MaxTokens)
|
|
if p.Temperature != nil {
|
|
fmt.Fprintf(&sb, "temperature=%g\n", *p.Temperature)
|
|
} else {
|
|
fmt.Fprintf(&sb, "temperature=\n")
|
|
}
|
|
if p.FrequencyPenalty != nil {
|
|
fmt.Fprintf(&sb, "frequencyPenalty=%g\n", *p.FrequencyPenalty)
|
|
} else {
|
|
fmt.Fprintf(&sb, "frequencyPenalty=\n")
|
|
}
|
|
if p.PresencePenalty != nil {
|
|
fmt.Fprintf(&sb, "presencePenalty=%g\n", *p.PresencePenalty)
|
|
} else {
|
|
fmt.Fprintf(&sb, "presencePenalty=\n")
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
func parseParams(input string, current backend.GenerationParams) (backend.GenerationParams, error) {
|
|
p := current
|
|
for _, line := range strings.Split(input, "\n") {
|
|
k, v, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
k = strings.TrimSpace(k)
|
|
v = strings.TrimSpace(v)
|
|
switch k {
|
|
case "maxTokens":
|
|
if v == "" {
|
|
p.MaxTokens = 0
|
|
} else {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return p, fmt.Errorf("invalid maxTokens: %s", v)
|
|
}
|
|
p.MaxTokens = n
|
|
}
|
|
case "temperature":
|
|
if v == "" {
|
|
p.Temperature = nil
|
|
} else {
|
|
f, err := strconv.ParseFloat(v, 64)
|
|
if err != nil {
|
|
return p, fmt.Errorf("invalid temperature: %s", v)
|
|
}
|
|
p.Temperature = &f
|
|
}
|
|
case "frequencyPenalty":
|
|
if v == "" {
|
|
p.FrequencyPenalty = nil
|
|
} else {
|
|
f, err := strconv.ParseFloat(v, 64)
|
|
if err != nil {
|
|
return p, fmt.Errorf("invalid frequencyPenalty: %s", v)
|
|
}
|
|
p.FrequencyPenalty = &f
|
|
}
|
|
case "presencePenalty":
|
|
if v == "" {
|
|
p.PresencePenalty = nil
|
|
} else {
|
|
f, err := strconv.ParseFloat(v, 64)
|
|
if err != nil {
|
|
return p, fmt.Errorf("invalid presencePenalty: %s", v)
|
|
}
|
|
p.PresencePenalty = &f
|
|
}
|
|
}
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// content returns the string content of a simple readable session file.
|
|
func (s *SessionFileStore) content(name string) string {
|
|
s.sess.mu.RLock()
|
|
defer s.sess.mu.RUnlock()
|
|
switch name {
|
|
case "backend":
|
|
return s.sess.core.BackendName() + "\n"
|
|
case "agent":
|
|
return s.sess.core.AgentName() + "\n"
|
|
case "model":
|
|
return s.sess.core.ModelName() + "\n"
|
|
case "state":
|
|
return s.sess.core.State() + "\n"
|
|
case "cwd":
|
|
return s.sess.core.CWD() + "\n"
|
|
case "usage":
|
|
return s.sess.core.Usage() + "\n"
|
|
case "ctxsz":
|
|
return s.sess.core.CtxSz() + "\n"
|
|
case "models":
|
|
return s.sess.core.ListModels() + "\n"
|
|
case "systemprompt":
|
|
return s.sess.core.SystemPrompt()
|
|
case "offset":
|
|
return fmt.Sprintf("%d\n", s.sess.chatOffset)
|
|
case "params":
|
|
return formatParams(s.sess.core.GenerationParams())
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// CurrentWaitValue returns the current value for the named *wait file.
|
|
func (s *SessionFileStore) CurrentWaitValue(name string) string {
|
|
switch name {
|
|
case "statewait":
|
|
return s.sess.core.State()
|
|
case "usagewait":
|
|
return s.sess.core.Usage()
|
|
case "ctxszwait":
|
|
return s.sess.core.CtxSz()
|
|
case "cwdwait":
|
|
return s.sess.core.CWD()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Wait blocks until the named *wait file's underlying value changes from base,
|
|
// then returns the new value. If base is empty, the current value is used.
|
|
// Unblocks when connCtx or the session context is cancelled.
|
|
func (s *SessionFileStore) Wait(connCtx context.Context, name, base string) ([]byte, error) {
|
|
ctx, cancel := context.WithCancel(connCtx)
|
|
defer cancel()
|
|
// also unblock when the session itself is killed.
|
|
context.AfterFunc(s.sess.ctx, cancel)
|
|
|
|
var field string
|
|
switch name {
|
|
case "statewait":
|
|
field = agent.WatchState
|
|
case "usagewait":
|
|
field = agent.WatchUsage
|
|
case "ctxszwait":
|
|
field = agent.WatchCtxSz
|
|
case "cwdwait":
|
|
field = agent.WatchCWD
|
|
default:
|
|
return nil, fmt.Errorf("%s: not a wait file", name)
|
|
}
|
|
|
|
if base == "" {
|
|
base = s.CurrentWaitValue(name)
|
|
}
|
|
|
|
v, ok := s.sess.core.WaitChange(ctx, field, base)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return []byte(v + "\n"), nil
|
|
}
|
|
|
|
func (s *SessionFileStore) makePublish() func(agent.Event) {
|
|
assistantStarted := false
|
|
return func(ev agent.Event) {
|
|
switch ev.Role {
|
|
case "user", "call", "tool":
|
|
assistantStarted = false
|
|
case "assistant":
|
|
if !assistantStarted {
|
|
s.sess.appendChat([]byte("assistant: "))
|
|
assistantStarted = true
|
|
}
|
|
}
|
|
s.sess.appendChat(store.FormatEvent(ev))
|
|
if ev.Role == "user" {
|
|
s.sess.mu.Lock()
|
|
s.sess.chatOffset = len(s.sess.chatLog)
|
|
s.sess.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *SessionFileStore) handleCtl(input string) error {
|
|
cmd := strings.Fields(input)
|
|
if len(cmd) == 0 {
|
|
return fmt.Errorf("empty ctl command")
|
|
}
|
|
switch cmd[0] {
|
|
case "stop":
|
|
s.sess.core.Interrupt(agent.ErrInterrupted)
|
|
case "kill":
|
|
s.kill()
|
|
case "rn":
|
|
if name := strings.TrimSpace(input[3:]); name != "" {
|
|
if err := s.rename(name); err != nil {
|
|
s.log.Error("rename: %v", err)
|
|
}
|
|
}
|
|
case "save":
|
|
s.sess.mu.RLock()
|
|
data := make([]byte, len(s.sess.chatLog))
|
|
copy(data, s.sess.chatLog)
|
|
s.sess.mu.RUnlock()
|
|
return s.saveTranscript(data)
|
|
case "compact", "clear", "backend", "model", "models",
|
|
"agents", "agent", "sessions", "cwd", "skills",
|
|
"tools", "context", "usage", "history",
|
|
"irw", "help":
|
|
s.sess.core.Submit(s.sess.ctx, "/"+input, s.makePublish())
|
|
default:
|
|
return fmt.Errorf("unknown ctl command: %s", cmd[0])
|
|
}
|
|
return nil
|
|
}
|