M cmd/llm9p/main.go => cmd/llm9p/main.go +4 -1
@@ 69,8 69,11 @@ func main() {
os.Exit(1)
}
+ // Create session manager for per-fid isolation
+ sm := llm.NewSessionManager(client)
+
// Create filesystem
- root := llmfs.NewRoot(client)
+ root := llmfs.NewRoot(sm)
// Create 9P server
server := protocol.NewServer(root)
M internal/llm/backend.go => internal/llm/backend.go +8 -0
@@ 22,6 22,11 @@ type Backend interface {
ThinkingTokens() int
// SetThinkingTokens sets the thinking token budget
SetThinkingTokens(tokens int)
+ // Prefill returns the assistant response prefill string
+ Prefill() string
+ // SetPrefill sets a string to prefill the assistant response
+ // This helps keep the model in character (e.g., "[Veltro] ")
+ SetPrefill(prefill string)
// LastTokens returns token count from last response
LastTokens() int
// TotalTokens returns cumulative token count for this conversation
@@ 41,6 46,9 @@ type Backend interface {
Reset()
// Ask sends a prompt and returns the response (blocking)
Ask(ctx context.Context, prompt string) (string, error)
+ // AskWithHistory sends a prompt with explicit message history (for per-fid isolation)
+ // Returns response text and token count
+ AskWithHistory(ctx context.Context, history []Message, prompt string) (string, int, error)
// StartStream begins streaming a response
StartStream(ctx context.Context, prompt string) error
// ReadStreamChunk reads the next streaming chunk
M internal/llm/cli_client.go => internal/llm/cli_client.go +106 -0
@@ 19,6 19,7 @@ type CLIClient struct {
model string
temperature float64
systemPrompt string
+ prefill string // assistant response prefill for keeping model in character
messages []Message
lastTokens int
totalTokens int // cumulative estimated token count
@@ 105,6 106,20 @@ func (c *CLIClient) SetThinkingTokens(tokens int) {
c.thinkingTokens = tokens
}
+// Prefill returns the assistant response prefill string
+func (c *CLIClient) Prefill() string {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return c.prefill
+}
+
+// SetPrefill sets a string to prefill the assistant response
+func (c *CLIClient) SetPrefill(prefill string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.prefill = prefill
+}
+
// SystemPrompt returns the current system prompt
func (c *CLIClient) SystemPrompt() string {
c.mu.RLock()
@@ 549,3 564,94 @@ func (c *CLIClient) WaitStream() {
<-done
}
}
+
+// AskWithHistory sends a prompt with explicit message history for per-fid isolation.
+// Unlike Ask(), this does not modify the client's internal messages state.
+// Returns response text and estimated token count.
+func (c *CLIClient) AskWithHistory(ctx context.Context, history []Message, prompt string) (string, int, error) {
+ // Get settings with lock
+ c.mu.RLock()
+ model := c.model
+ thinkingTokens := c.thinkingTokens
+ systemPromptSetting := c.systemPrompt
+ prefill := c.prefill
+ c.mu.RUnlock()
+
+ // Build prompt from provided history
+ var parts []string
+ var systemParts []string
+
+ // Add dedicated system prompt first
+ if systemPromptSetting != "" {
+ systemParts = append(systemParts, systemPromptSetting)
+ }
+
+ for _, msg := range history {
+ switch msg.Role {
+ case "system":
+ systemParts = append(systemParts, msg.Content)
+ case "user":
+ parts = append(parts, fmt.Sprintf("Human: %s", msg.Content))
+ case "assistant":
+ parts = append(parts, fmt.Sprintf("Assistant: %s", msg.Content))
+ }
+ }
+
+ // Add the new user prompt
+ parts = append(parts, fmt.Sprintf("Human: %s", prompt))
+
+ fullPrompt := strings.Join(parts, "\n\n")
+ systemPrompt := strings.Join(systemParts, "\n\n")
+
+ // Build claude CLI command
+ args := []string{
+ "--print",
+ "--output-format", "json",
+ "--model", model,
+ "--allowedTools", "",
+ "--dangerously-skip-permissions",
+ }
+
+ if systemPrompt != "" {
+ args = append(args, "--system-prompt", systemPrompt)
+ }
+
+ args = append(args, "-") // Read from stdin
+
+ cmd := exec.CommandContext(ctx, "claude", args...)
+ cmd.Stdin = bytes.NewBufferString(fullPrompt)
+
+ // Set thinking token budget via environment variable
+ cmd.Env = append(cmd.Environ(), func() string {
+ if thinkingTokens < 0 {
+ return "MAX_THINKING_TOKENS=31999"
+ }
+ return fmt.Sprintf("MAX_THINKING_TOKENS=%d", thinkingTokens)
+ }())
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ return "", 0, fmt.Errorf("claude CLI error: %w (stderr: %s)", err, stderr.String())
+ }
+
+ // Parse JSON response
+ responseText, err := parseJSONResponse(stdout.String())
+ if err != nil {
+ return "", 0, fmt.Errorf("failed to parse CLI response: %w", err)
+ }
+
+ // Prepend prefill to response to keep model in character
+ // Note: CLI doesn't support true prefill (partial assistant message),
+ // so we prepend it to the response for consistent behavior with API client
+ if prefill != "" {
+ responseText = prefill + responseText
+ }
+
+ // Estimate tokens: prompt + response (chars / 4)
+ tokens := estimateTokens(fullPrompt) + estimateTokens(responseText)
+
+ return responseText, tokens, nil
+}
M internal/llm/client.go => internal/llm/client.go +141 -1
@@ 7,6 7,7 @@ import (
"fmt"
"strings"
"sync"
+ "time"
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
@@ 18,6 19,24 @@ type Message struct {
Content string `json:"content"` // message content
}
+// MetricsCallback is called after each LLM request with performance data
+type MetricsCallback func(inputTokens, outputTokens int, latencyMs int64)
+
+// Global metrics callback - set by llmfs to record metrics
+var metricsCallback MetricsCallback
+
+// SetMetricsCallback registers a callback for recording metrics
+func SetMetricsCallback(cb MetricsCallback) {
+ metricsCallback = cb
+}
+
+// RecordMetrics calls the registered callback if set
+func RecordMetrics(inputTokens, outputTokens int, latencyMs int64) {
+ if metricsCallback != nil {
+ metricsCallback(inputTokens, outputTokens, latencyMs)
+ }
+}
+
// Client wraps the Anthropic API client with conversation state
type Client struct {
client anthropic.Client
@@ 25,6 44,7 @@ type Client struct {
model string
temperature float64
systemPrompt string
+ prefill string // assistant response prefill for keeping model in character
messages []Message
lastTokens int
totalTokens int // cumulative token count for context tracking
@@ 93,6 113,20 @@ func (c *Client) SetThinkingTokens(tokens int) {
c.thinkingTokens = tokens
}
+// Prefill returns the assistant response prefill string
+func (c *Client) Prefill() string {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return c.prefill
+}
+
+// SetPrefill sets a string to prefill the assistant response
+func (c *Client) SetPrefill(prefill string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.prefill = prefill
+}
+
// SystemPrompt returns the current system prompt
func (c *Client) SystemPrompt() string {
c.mu.RLock()
@@ 286,8 320,11 @@ func (c *Client) Ask(ctx context.Context, prompt string) (string, error) {
params.System = systemBlocks
}
- // Make the API call
+ // Make the API call with timing
+ startTime := time.Now()
response, err := c.client.Messages.New(ctx, params)
+ latencyMs := time.Since(startTime).Milliseconds()
+
if err != nil {
// Remove the user message on error
c.mu.Lock()
@@ 313,6 350,11 @@ func (c *Client) Ask(ctx context.Context, prompt string) (string, error) {
c.totalTokens += c.lastTokens
c.mu.Unlock()
+ // Record metrics (input and output tokens separately for analysis)
+ inputToks := int(response.Usage.InputTokens)
+ outputToks := int(response.Usage.OutputTokens)
+ RecordMetrics(inputToks, outputToks, latencyMs)
+
return responseText, nil
}
@@ 472,3 514,101 @@ func (c *Client) WaitStream() {
<-done
}
}
+
+// AskWithHistory sends a prompt with explicit message history for per-fid isolation.
+// Unlike Ask(), this does not modify the client's internal messages state.
+// Returns response text and token count.
+func (c *Client) AskWithHistory(ctx context.Context, history []Message, prompt string) (string, int, error) {
+ // Get settings with lock
+ c.mu.RLock()
+ model := c.model
+ temp := c.temperature
+ systemPrompt := c.systemPrompt
+ prefill := c.prefill
+ c.mu.RUnlock()
+
+ // Build API messages from provided history plus the new prompt
+ apiMessages := make([]anthropic.MessageParam, 0, len(history)+2)
+ var systemBlocks []anthropic.TextBlockParam
+
+ // Add dedicated system prompt first
+ if systemPrompt != "" {
+ systemBlocks = append(systemBlocks, anthropic.TextBlockParam{
+ Text: systemPrompt,
+ })
+ }
+
+ for _, msg := range history {
+ switch msg.Role {
+ case "system":
+ systemBlocks = append(systemBlocks, anthropic.TextBlockParam{
+ Text: msg.Content,
+ })
+ case "user":
+ apiMessages = append(apiMessages, anthropic.NewUserMessage(
+ anthropic.NewTextBlock(msg.Content),
+ ))
+ case "assistant":
+ apiMessages = append(apiMessages, anthropic.NewAssistantMessage(
+ anthropic.NewTextBlock(msg.Content),
+ ))
+ }
+ }
+
+ // Add the new user prompt
+ apiMessages = append(apiMessages, anthropic.NewUserMessage(
+ anthropic.NewTextBlock(prompt),
+ ))
+
+ // Add prefill as partial assistant message to keep model in character
+ // The model will continue from this point
+ if prefill != "" {
+ apiMessages = append(apiMessages, anthropic.NewAssistantMessage(
+ anthropic.NewTextBlock(prefill),
+ ))
+ }
+
+ // Build request params
+ params := anthropic.MessageNewParams{
+ Model: anthropic.Model(model),
+ MaxTokens: 4096,
+ Messages: apiMessages,
+ Temperature: anthropic.Float(temp),
+ }
+
+ // Add system prompt if present
+ if len(systemBlocks) > 0 {
+ params.System = systemBlocks
+ }
+
+ // Make the API call with timing
+ startTime := time.Now()
+ response, err := c.client.Messages.New(ctx, params)
+ latencyMs := time.Since(startTime).Milliseconds()
+
+ if err != nil {
+ return "", 0, fmt.Errorf("API error: %w", err)
+ }
+
+ // Extract response text
+ var responseText string
+ for _, block := range response.Content {
+ if block.Type == "text" {
+ responseText += block.Text
+ }
+ }
+
+ // Prepend prefill to response (it was used as partial assistant message)
+ if prefill != "" {
+ responseText = prefill + responseText
+ }
+
+ tokens := int(response.Usage.InputTokens + response.Usage.OutputTokens)
+
+ // Record metrics
+ inputToks := int(response.Usage.InputTokens)
+ outputToks := int(response.Usage.OutputTokens)
+ RecordMetrics(inputToks, outputToks, latencyMs)
+
+ return responseText, tokens, nil
+}
A internal/llm/session.go => internal/llm/session.go +192 -0
@@ 0,0 1,192 @@
+// Package llm provides LLM backends for the 9P filesystem.
+package llm
+
+import (
+ "context"
+ "encoding/json"
+ "sync"
+)
+
+// Session holds per-fid conversation state.
+// Each fid that opens the ask file gets its own session with isolated history.
+type Session struct {
+ ID uint32
+ messages []Message
+ lastResponse string
+ lastTokens int
+ totalTokens int
+ mu sync.RWMutex
+}
+
+// NewSession creates a new session for the given fid.
+func NewSession(fid uint32) *Session {
+ return &Session{
+ ID: fid,
+ messages: make([]Message, 0),
+ }
+}
+
+// Messages returns a copy of the session's conversation history.
+func (s *Session) Messages() []Message {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ result := make([]Message, len(s.messages))
+ copy(result, s.messages)
+ return result
+}
+
+// MessagesJSON returns the session's conversation history as JSON.
+func (s *Session) MessagesJSON() ([]byte, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return json.MarshalIndent(s.messages, "", " ")
+}
+
+// AddMessage adds a message to the session's history.
+func (s *Session) AddMessage(role, content string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.messages = append(s.messages, Message{Role: role, Content: content})
+}
+
+// AddSystemMessage adds a system message to the session's history.
+func (s *Session) AddSystemMessage(content string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.messages = append([]Message{{Role: "system", Content: content}}, s.messages...)
+}
+
+// SetLastResponse sets the last response for this session.
+func (s *Session) SetLastResponse(response string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.lastResponse = response
+}
+
+// LastResponse returns the last response for this session.
+func (s *Session) LastResponse() string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.lastResponse
+}
+
+// LastTokens returns the token count from the last response.
+func (s *Session) LastTokens() int {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.lastTokens
+}
+
+// TotalTokens returns cumulative token count for this session.
+func (s *Session) TotalTokens() int {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.totalTokens
+}
+
+// SetTokens updates the token counts for this session.
+func (s *Session) SetTokens(last, total int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.lastTokens = last
+ s.totalTokens = total
+}
+
+// AddTokens adds to the token counts for this session.
+func (s *Session) AddTokens(tokens int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.lastTokens = tokens
+ s.totalTokens += tokens
+}
+
+// Reset clears the session's conversation history.
+func (s *Session) Reset() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.messages = make([]Message, 0)
+ s.lastResponse = ""
+ s.lastTokens = 0
+ s.totalTokens = 0
+}
+
+// SessionManager maps fids to sessions and delegates to a shared backend.
+type SessionManager struct {
+ sessions map[uint32]*Session
+ backend Backend // shared backend for API calls and global settings
+ mu sync.RWMutex
+}
+
+// NewSessionManager creates a new session manager with the given backend.
+func NewSessionManager(backend Backend) *SessionManager {
+ return &SessionManager{
+ sessions: make(map[uint32]*Session),
+ backend: backend,
+ }
+}
+
+// Backend returns the underlying shared backend.
+func (sm *SessionManager) Backend() Backend {
+ return sm.backend
+}
+
+// GetOrCreate returns the session for the given fid, creating one if necessary.
+func (sm *SessionManager) GetOrCreate(fid uint32) *Session {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+ if s, ok := sm.sessions[fid]; ok {
+ return s
+ }
+ s := NewSession(fid)
+ sm.sessions[fid] = s
+ return s
+}
+
+// Get returns the session for the given fid, or nil if it doesn't exist.
+func (sm *SessionManager) Get(fid uint32) *Session {
+ sm.mu.RLock()
+ defer sm.mu.RUnlock()
+ return sm.sessions[fid]
+}
+
+// Remove removes the session for the given fid.
+func (sm *SessionManager) Remove(fid uint32) {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+ delete(sm.sessions, fid)
+}
+
+// Reset clears the session for the given fid (but keeps the session).
+func (sm *SessionManager) Reset(fid uint32) {
+ session := sm.GetOrCreate(fid)
+ session.Reset()
+}
+
+// Ask sends a prompt using the session's conversation history.
+// The response is stored in the session and returned.
+func (sm *SessionManager) Ask(ctx context.Context, fid uint32, prompt string) (string, error) {
+ session := sm.GetOrCreate(fid)
+
+ // Get current history before adding new message
+ history := session.Messages()
+
+ // Use backend's AskWithHistory - it doesn't modify backend state
+ response, tokens, err := sm.backend.AskWithHistory(ctx, history, prompt)
+ if err != nil {
+ session.SetLastResponse("Error: " + err.Error())
+ return "", err
+ }
+
+ // Add user message and assistant response to session history
+ session.AddMessage("user", prompt)
+ session.AddMessage("assistant", response)
+ session.AddTokens(tokens)
+ session.SetLastResponse(response)
+
+ return response, nil
+}
+
+// ContextLimit returns the model's context window limit from the backend.
+func (sm *SessionManager) ContextLimit() int {
+ return sm.backend.ContextLimit()
+}
M internal/llmfs/ask.go => internal/llmfs/ask.go +43 -39
@@ 5,7 5,6 @@ import (
"io"
"log"
"strings"
- "sync"
"github.com/NERVsystems/llm9p/internal/llm"
"github.com/NERVsystems/llm9p/internal/protocol"
@@ 14,26 13,38 @@ import (
// CompactThreshold is the percentage of context limit at which auto-compaction triggers
const CompactThreshold = 0.80
-// AskFile is the main interaction file - write a prompt, read the response
+// AskFile is the main interaction file - write a prompt, read the response.
+// It implements FidAwareFile to provide per-fid session isolation.
type AskFile struct {
*protocol.BaseFile
- client llm.Backend
- mu sync.RWMutex
- lastResponse string
+ sm *llm.SessionManager
}
// NewAskFile creates the ask file
-func NewAskFile(client llm.Backend) *AskFile {
+func NewAskFile(sm *llm.SessionManager) *AskFile {
return &AskFile{
BaseFile: protocol.NewBaseFile("ask", 0666),
- client: client,
+ sm: sm,
}
}
+// Read implements File.Read (fallback for non-fid-aware access)
func (f *AskFile) Read(p []byte, offset int64) (int, error) {
- f.mu.RLock()
- content := f.lastResponse
- f.mu.RUnlock()
+ // Without fid context, we can't return session-specific data
+ // Return empty to indicate no data available
+ return 0, io.EOF
+}
+
+// Write implements File.Write (fallback for non-fid-aware access)
+func (f *AskFile) Write(p []byte, offset int64) (int, error) {
+ // Without fid context, we can't process the request properly
+ return 0, protocol.ErrPermission
+}
+
+// ReadFid implements FidAwareFile.ReadFid
+func (f *AskFile) ReadFid(fid uint32, p []byte, offset int64) (int, error) {
+ session := f.sm.GetOrCreate(fid)
+ content := session.LastResponse()
// Add newline if not present
if content != "" && !strings.HasSuffix(content, "\n") {
@@ 47,7 58,8 @@ func (f *AskFile) Read(p []byte, offset int64) (int, error) {
return n, nil
}
-func (f *AskFile) Write(p []byte, offset int64) (int, error) {
+// WriteFid implements FidAwareFile.WriteFid
+func (f *AskFile) WriteFid(fid uint32, p []byte, offset int64) (int, error) {
prompt := strings.TrimSpace(string(p))
if prompt == "" {
return len(p), nil // Empty write is a no-op
@@ 56,47 68,39 @@ func (f *AskFile) Write(p []byte, offset int64) (int, error) {
ctx := context.Background()
// Check if we need to auto-compact before processing
- tokens := f.client.TotalTokens()
- limit := f.client.ContextLimit()
+ session := f.sm.GetOrCreate(fid)
+ tokens := session.TotalTokens()
+ limit := f.sm.ContextLimit()
threshold := int(float64(limit) * CompactThreshold)
if tokens > threshold {
- log.Printf("llm9p: auto-compacting at %d/%d tokens (%.0f%% threshold)",
- tokens, limit, CompactThreshold*100)
- if err := f.client.Compact(ctx); err != nil {
- log.Printf("llm9p: auto-compact failed: %v", err)
- // Continue anyway - better to try than to fail
- } else {
- log.Printf("llm9p: auto-compact complete, now at %d tokens",
- f.client.TotalTokens())
- }
+ log.Printf("llm9p: fid %d at %d/%d tokens (%.0f%% threshold) - consider resetting",
+ fid, tokens, limit, CompactThreshold*100)
+ // Note: Per-session compaction would require a different approach
+ // For now, just log a warning. Session reset via /new is the solution.
}
- // Make the API call
- response, err := f.client.Ask(ctx, prompt)
+ // Make the API call using the session
+ _, err := f.sm.Ask(ctx, fid, prompt)
if err != nil {
- // Store error as response so it can be read
- f.mu.Lock()
- f.lastResponse = "Error: " + err.Error()
- f.mu.Unlock()
+ // Error is stored in session.LastResponse by SessionManager
return len(p), nil // Return success so client knows write completed
}
- f.mu.Lock()
- f.lastResponse = response
- f.mu.Unlock()
-
return len(p), nil
}
+// CloseFid implements FidAwareFile.CloseFid
+func (f *AskFile) CloseFid(fid uint32) error {
+ // Clean up the session when the fid is clunked
+ f.sm.Remove(fid)
+ return nil
+}
+
+// Stat returns the file's metadata
func (f *AskFile) Stat() protocol.Stat {
s := f.BaseFile.Stat()
- f.mu.RLock()
- content := f.lastResponse
- f.mu.RUnlock()
- if content != "" && !strings.HasSuffix(content, "\n") {
- content += "\n"
- }
- s.Length = uint64(len(content))
+ // Length is dynamic based on session, but without fid context we return 0
+ s.Length = 0
return s
}
M internal/llmfs/ask_test.go => internal/llmfs/ask_test.go +92 -86
@@ 3,20 3,23 @@ package llmfs
import (
"io"
"testing"
+
+ "github.com/NERVsystems/llm9p/internal/llm"
)
func TestAskFile_Read_Empty(t *testing.T) {
mock := NewMockBackend()
- ask := NewAskFile(mock)
+ sm := llm.NewSessionManager(mock)
+ ask := NewAskFile(sm)
- // Initial read should be empty (EOF)
+ // Initial read with fid should be empty (EOF)
buf := make([]byte, 100)
- n, err := ask.Read(buf, 0)
+ n, err := ask.ReadFid(1, buf, 0)
if err != io.EOF {
- t.Errorf("Read() error = %v, want io.EOF", err)
+ t.Errorf("ReadFid() error = %v, want io.EOF", err)
}
if n != 0 {
- t.Errorf("Read() n = %d, want 0", n)
+ t.Errorf("ReadFid() n = %d, want 0", n)
}
}
@@ 24,49 27,55 @@ func TestAskFile_WriteRead(t *testing.T) {
mock := NewMockBackend()
mock.askResponse = "Hello, I'm Claude!"
- ask := NewAskFile(mock)
+ sm := llm.NewSessionManager(mock)
+ ask := NewAskFile(sm)
+
+ fid := uint32(1)
// Write a prompt
prompt := "Hello!"
- n, err := ask.Write([]byte(prompt), 0)
+ n, err := ask.WriteFid(fid, []byte(prompt), 0)
if err != nil {
- t.Fatalf("Write() error: %v", err)
+ t.Fatalf("WriteFid() error: %v", err)
}
if n != len(prompt) {
- t.Errorf("Write() n = %d, want %d", n, len(prompt))
+ t.Errorf("WriteFid() n = %d, want %d", n, len(prompt))
}
// Read response
buf := make([]byte, 100)
- readN, err := ask.Read(buf, 0)
+ readN, err := ask.ReadFid(fid, buf, 0)
if err != nil {
- t.Fatalf("Read() error: %v", err)
+ t.Fatalf("ReadFid() error: %v", err)
}
response := string(buf[:readN])
expected := "Hello, I'm Claude!\n"
if response != expected {
- t.Errorf("Read() = %q, want %q", response, expected)
+ t.Errorf("ReadFid() = %q, want %q", response, expected)
}
}
func TestAskFile_Write_EmptyNoOp(t *testing.T) {
mock := NewMockBackend()
- ask := NewAskFile(mock)
+ sm := llm.NewSessionManager(mock)
+ ask := NewAskFile(sm)
+
+ fid := uint32(1)
// Empty write should be no-op
- n, err := ask.Write([]byte(""), 0)
+ n, err := ask.WriteFid(fid, []byte(""), 0)
if err != nil {
- t.Fatalf("Write() error: %v", err)
+ t.Fatalf("WriteFid() error: %v", err)
}
if n != 0 {
- t.Errorf("Write('') n = %d, want 0", n)
+ t.Errorf("WriteFid('') n = %d, want 0", n)
}
// Whitespace-only also no-op
- n, err = ask.Write([]byte(" \n\t "), 0)
+ n, err = ask.WriteFid(fid, []byte(" \n\t "), 0)
if err != nil {
- t.Fatalf("Write() error: %v", err)
+ t.Fatalf("WriteFid() error: %v", err)
}
}
@@ 74,88 83,89 @@ func TestAskFile_Write_Error(t *testing.T) {
mock := NewMockBackend()
mock.askError = io.ErrUnexpectedEOF
- ask := NewAskFile(mock)
+ sm := llm.NewSessionManager(mock)
+ ask := NewAskFile(sm)
+
+ fid := uint32(1)
// Write should succeed (error is stored for read)
- n, err := ask.Write([]byte("test"), 0)
+ n, err := ask.WriteFid(fid, []byte("test"), 0)
if err != nil {
- t.Fatalf("Write() error: %v", err)
+ t.Fatalf("WriteFid() error: %v", err)
}
if n != 4 {
- t.Errorf("Write() n = %d, want 4", n)
+ t.Errorf("WriteFid() n = %d, want 4", n)
}
// Read should return the error
buf := make([]byte, 100)
- readN, _ := ask.Read(buf, 0)
+ readN, _ := ask.ReadFid(fid, buf, 0)
response := string(buf[:readN])
- if response[:6] != "Error:" {
- t.Errorf("Read() = %q, should start with 'Error:'", response)
+ if len(response) < 6 || response[:6] != "Error:" {
+ t.Errorf("ReadFid() = %q, should start with 'Error:'", response)
}
}
-func TestAskFile_AutoCompaction_BelowThreshold(t *testing.T) {
+func TestAskFile_SessionIsolation(t *testing.T) {
mock := NewMockBackend()
- mock.totalTokens = 100000 // 50% of 200K, below 80% threshold
- mock.contextLimit = 200000
mock.askResponse = "response"
- ask := NewAskFile(mock)
+ sm := llm.NewSessionManager(mock)
+ ask := NewAskFile(sm)
- // Write should NOT trigger compaction
- ask.Write([]byte("test"), 0)
+ fid1 := uint32(1)
+ fid2 := uint32(2)
- if mock.compactCalled {
- t.Error("Compact() should not be called below threshold")
- }
-}
+ // Write to fid1
+ mock.askResponse = "response for fid1"
+ ask.WriteFid(fid1, []byte("prompt1"), 0)
-func TestAskFile_AutoCompaction_AboveThreshold(t *testing.T) {
- mock := NewMockBackend()
- mock.totalTokens = 170000 // 85% of 200K, above 80% threshold
- mock.contextLimit = 200000
- mock.askResponse = "response"
-
- ask := NewAskFile(mock)
+ // Write to fid2
+ mock.askResponse = "response for fid2"
+ ask.WriteFid(fid2, []byte("prompt2"), 0)
- // Write SHOULD trigger compaction
- ask.Write([]byte("test"), 0)
+ // Read from fid1 - should get fid1's response
+ buf := make([]byte, 100)
+ n, _ := ask.ReadFid(fid1, buf, 0)
+ response1 := string(buf[:n])
+ if response1 != "response for fid1\n" {
+ t.Errorf("fid1 ReadFid() = %q, want %q", response1, "response for fid1\n")
+ }
- if !mock.compactCalled {
- t.Error("Compact() should be called when above threshold")
+ // Read from fid2 - should get fid2's response
+ n, _ = ask.ReadFid(fid2, buf, 0)
+ response2 := string(buf[:n])
+ if response2 != "response for fid2\n" {
+ t.Errorf("fid2 ReadFid() = %q, want %q", response2, "response for fid2\n")
}
}
-func TestAskFile_AutoCompaction_ExactThreshold(t *testing.T) {
+func TestAskFile_CloseFid(t *testing.T) {
mock := NewMockBackend()
- mock.totalTokens = 160000 // Exactly 80% of 200K
- mock.contextLimit = 200000
mock.askResponse = "response"
- ask := NewAskFile(mock)
+ sm := llm.NewSessionManager(mock)
+ ask := NewAskFile(sm)
- // Write should NOT trigger (we use > not >=)
- ask.Write([]byte("test"), 0)
+ fid := uint32(1)
- if mock.compactCalled {
- t.Error("Compact() should not be called at exactly threshold")
- }
-}
-
-func TestAskFile_AutoCompaction_JustAboveThreshold(t *testing.T) {
- mock := NewMockBackend()
- mock.totalTokens = 160001 // Just over 80%
- mock.contextLimit = 200000
- mock.askResponse = "response"
+ // Write to create session
+ ask.WriteFid(fid, []byte("test"), 0)
- ask := NewAskFile(mock)
+ // Session should exist
+ session := sm.Get(fid)
+ if session == nil {
+ t.Fatal("session should exist after WriteFid")
+ }
- // Write SHOULD trigger compaction
- ask.Write([]byte("test"), 0)
+ // Close the fid
+ ask.CloseFid(fid)
- if !mock.compactCalled {
- t.Error("Compact() should be called when just above threshold")
+ // Session should be removed
+ session = sm.Get(fid)
+ if session != nil {
+ t.Error("session should be removed after CloseFid")
}
}
@@ 163,35 173,29 @@ func TestAskFile_Stat(t *testing.T) {
mock := NewMockBackend()
mock.askResponse = "Hello!"
- ask := NewAskFile(mock)
+ sm := llm.NewSessionManager(mock)
+ ask := NewAskFile(sm)
- // Initial stat - no response yet
+ // Stat without fid context returns 0 length
stat := ask.Stat()
if stat.Length != 0 {
- t.Errorf("Stat().Length = %d, want 0 (no response yet)", stat.Length)
- }
-
- // Write to get a response
- ask.Write([]byte("test"), 0)
-
- // Stat should now show response length (with newline)
- stat = ask.Stat()
- expected := uint64(len("Hello!\n"))
- if stat.Length != expected {
- t.Errorf("Stat().Length = %d, want %d", stat.Length, expected)
+ t.Errorf("Stat().Length = %d, want 0", stat.Length)
}
}
func TestAskFile_ResponseNewline(t *testing.T) {
mock := NewMockBackend()
+ sm := llm.NewSessionManager(mock)
+
+ fid := uint32(1)
// Response without trailing newline
mock.askResponse = "No newline"
- ask := NewAskFile(mock)
- ask.Write([]byte("test"), 0)
+ ask := NewAskFile(sm)
+ ask.WriteFid(fid, []byte("test"), 0)
buf := make([]byte, 100)
- n, _ := ask.Read(buf, 0)
+ n, _ := ask.ReadFid(fid, buf, 0)
response := string(buf[:n])
// Should have newline added
@@ 199,12 203,14 @@ func TestAskFile_ResponseNewline(t *testing.T) {
t.Errorf("Response should end with newline, got %q", response)
}
+ // New session for second test
+ fid2 := uint32(2)
+
// Response with trailing newline already
mock.askResponse = "Has newline\n"
- ask2 := NewAskFile(mock)
- ask2.Write([]byte("test"), 0)
+ ask.WriteFid(fid2, []byte("test"), 0)
- n, _ = ask2.Read(buf, 0)
+ n, _ = ask.ReadFid(fid2, buf, 0)
response = string(buf[:n])
// Should NOT double the newline
M internal/llmfs/context.go => internal/llmfs/context.go +37 -12
@@ 8,24 8,39 @@ import (
"github.com/NERVsystems/llm9p/internal/protocol"
)
-// ContextFile exposes the conversation history
-// Read: returns JSON of conversation history
-// Write: appends a system message to context
+// ContextFile exposes the conversation history.
+// It implements FidAwareFile to provide per-fid session isolation.
+// Read: returns JSON of conversation history for this fid's session
+// Write: appends a system message to this fid's session context
type ContextFile struct {
*protocol.BaseFile
- client llm.Backend
+ sm *llm.SessionManager
}
// NewContextFile creates the context file
-func NewContextFile(client llm.Backend) *ContextFile {
+func NewContextFile(sm *llm.SessionManager) *ContextFile {
return &ContextFile{
BaseFile: protocol.NewBaseFile("context", 0666),
- client: client,
+ sm: sm,
}
}
+// Read implements File.Read (fallback for non-fid-aware access)
func (f *ContextFile) Read(p []byte, offset int64) (int, error) {
- content, err := f.client.MessagesJSON()
+ // Without fid context, return empty
+ return 0, io.EOF
+}
+
+// Write implements File.Write (fallback for non-fid-aware access)
+func (f *ContextFile) Write(p []byte, offset int64) (int, error) {
+ // Without fid context, we can't add to a specific session
+ return 0, protocol.ErrPermission
+}
+
+// ReadFid implements FidAwareFile.ReadFid
+func (f *ContextFile) ReadFid(fid uint32, p []byte, offset int64) (int, error) {
+ session := f.sm.GetOrCreate(fid)
+ content, err := session.MessagesJSON()
if err != nil {
return 0, err
}
@@ 39,18 54,28 @@ func (f *ContextFile) Read(p []byte, offset int64) (int, error) {
return n, nil
}
-func (f *ContextFile) Write(p []byte, offset int64) (int, error) {
- // Writing appends a system message to the context
+// WriteFid implements FidAwareFile.WriteFid
+func (f *ContextFile) WriteFid(fid uint32, p []byte, offset int64) (int, error) {
+ // Writing appends a system message to this fid's session context
msg := strings.TrimSpace(string(p))
if msg != "" {
- f.client.AddSystemMessage(msg)
+ session := f.sm.GetOrCreate(fid)
+ session.AddSystemMessage(msg)
}
return len(p), nil
}
+// CloseFid implements FidAwareFile.CloseFid
+func (f *ContextFile) CloseFid(fid uint32) error {
+ // No per-fid state to clean up for this file
+ // (session cleanup is handled by AskFile)
+ return nil
+}
+
+// Stat returns the file's metadata
func (f *ContextFile) Stat() protocol.Stat {
s := f.BaseFile.Stat()
- content, _ := f.client.MessagesJSON()
- s.Length = uint64(len(content) + 1) // +1 for newline
+ // Length is dynamic based on session, but without fid context we return 0
+ s.Length = 0
return s
}
M internal/llmfs/mock_backend_test.go => internal/llmfs/mock_backend_test.go +11 -0
@@ 12,6 12,7 @@ type MockBackend struct {
model string
temperature float64
systemPrompt string
+ prefill string
messages []llm.Message
lastTokens int
totalTokens int
@@ 46,6 47,8 @@ func (m *MockBackend) SystemPrompt() string { return m.systemPrompt }
func (m *MockBackend) SetSystemPrompt(prompt string) { m.systemPrompt = prompt }
func (m *MockBackend) ThinkingTokens() int { return m.thinkingTokens }
func (m *MockBackend) SetThinkingTokens(tokens int) { m.thinkingTokens = tokens }
+func (m *MockBackend) Prefill() string { return m.prefill }
+func (m *MockBackend) SetPrefill(prefill string) { m.prefill = prefill }
func (m *MockBackend) LastTokens() int { return m.lastTokens }
func (m *MockBackend) TotalTokens() int { return m.totalTokens }
func (m *MockBackend) ContextLimit() int { return m.contextLimit }
@@ 92,6 95,14 @@ func (m *MockBackend) Ask(ctx context.Context, prompt string) (string, error) {
return m.askResponse, nil
}
+func (m *MockBackend) AskWithHistory(ctx context.Context, history []llm.Message, prompt string) (string, int, error) {
+ if m.askError != nil {
+ return "", 0, m.askError
+ }
+ tokens := len(prompt) + len(m.askResponse)
+ return m.askResponse, tokens, nil
+}
+
func (m *MockBackend) StartStream(ctx context.Context, prompt string) error {
return fmt.Errorf("streaming not implemented in mock")
}
M internal/llmfs/new.go => internal/llmfs/new.go +27 -6
@@ 5,30 5,51 @@ import (
"github.com/NERVsystems/llm9p/internal/protocol"
)
-// NewFile is a write-only file that resets the conversation when written to
+// NewFile is a write-only file that resets the conversation when written to.
+// It implements FidAwareFile to reset only the session for the writing fid.
type NewFile struct {
*protocol.BaseFile
- client llm.Backend
+ sm *llm.SessionManager
}
// NewNewFile creates the new file
-func NewNewFile(client llm.Backend) *NewFile {
+func NewNewFile(sm *llm.SessionManager) *NewFile {
return &NewFile{
BaseFile: protocol.NewBaseFile("new", 0222),
- client: client,
+ sm: sm,
}
}
+// Read implements File.Read
func (f *NewFile) Read(p []byte, offset int64) (int, error) {
return 0, protocol.ErrPermission
}
+// Write implements File.Write (fallback for non-fid-aware access)
func (f *NewFile) Write(p []byte, offset int64) (int, error) {
- // Any write resets the conversation
- f.client.Reset()
+ // Without fid context, we can't reset a specific session
+ return 0, protocol.ErrPermission
+}
+
+// ReadFid implements FidAwareFile.ReadFid
+func (f *NewFile) ReadFid(fid uint32, p []byte, offset int64) (int, error) {
+ return 0, protocol.ErrPermission
+}
+
+// WriteFid implements FidAwareFile.WriteFid
+func (f *NewFile) WriteFid(fid uint32, p []byte, offset int64) (int, error) {
+ // Reset only the session for this fid
+ f.sm.Reset(fid)
return len(p), nil
}
+// CloseFid implements FidAwareFile.CloseFid
+func (f *NewFile) CloseFid(fid uint32) error {
+ // No per-fid state to clean up for this file
+ return nil
+}
+
+// Stat returns the file's metadata
func (f *NewFile) Stat() protocol.Stat {
s := f.BaseFile.Stat()
s.Length = 0
A internal/llmfs/prefill.go => internal/llmfs/prefill.go +54 -0
@@ 0,0 1,54 @@
+package llmfs
+
+import (
+ "io"
+ "strings"
+
+ "github.com/NERVsystems/llm9p/internal/llm"
+ "github.com/NERVsystems/llm9p/internal/protocol"
+)
+
+// PrefillFile exposes the assistant response prefill (read/write).
+// Prefill helps keep the model in character by prepending a string
+// to the assistant's response (e.g., "[Veltro] ").
+type PrefillFile struct {
+ *protocol.BaseFile
+ client llm.Backend
+}
+
+// NewPrefillFile creates the prefill file
+func NewPrefillFile(client llm.Backend) *PrefillFile {
+ return &PrefillFile{
+ BaseFile: protocol.NewBaseFile("prefill", 0666),
+ client: client,
+ }
+}
+
+func (f *PrefillFile) Read(p []byte, offset int64) (int, error) {
+ content := f.client.Prefill()
+ if content != "" {
+ content += "\n"
+ }
+ if offset >= int64(len(content)) {
+ return 0, io.EOF
+ }
+ n := copy(p, content[offset:])
+ return n, nil
+}
+
+func (f *PrefillFile) Write(p []byte, offset int64) (int, error) {
+ prefill := strings.TrimSpace(string(p))
+ f.client.SetPrefill(prefill)
+ return len(p), nil
+}
+
+func (f *PrefillFile) Stat() protocol.Stat {
+ s := f.BaseFile.Stat()
+ content := f.client.Prefill()
+ if content != "" {
+ s.Length = uint64(len(content) + 1) // +1 for newline
+ } else {
+ s.Length = 0
+ }
+ return s
+}
M internal/llmfs/root.go => internal/llmfs/root.go +27 -16
@@ 6,27 6,38 @@ import (
"github.com/NERVsystems/llm9p/internal/protocol"
)
-// NewRoot creates the root directory of the LLM filesystem
-func NewRoot(client llm.Backend) protocol.Dir {
+// NewRoot creates the root directory of the LLM filesystem.
+// It takes a SessionManager which provides per-fid session isolation
+// and access to the underlying backend for global settings.
+func NewRoot(sm *llm.SessionManager) protocol.Dir {
+ backend := sm.Backend()
root := protocol.NewStaticDir("llm")
- // Add all files
- root.AddChild(NewAskFile(client))
- root.AddChild(NewModelFile(client))
- root.AddChild(NewTemperatureFile(client))
- root.AddChild(NewSystemFile(client))
- root.AddChild(NewTokensFile(client))
- root.AddChild(NewNewFile(client))
- root.AddChild(NewContextFile(client))
- root.AddChild(NewThinkingFile(client))
+ // Session-aware files (per-fid isolation)
+ root.AddChild(NewAskFile(sm))
+ root.AddChild(NewNewFile(sm))
+ root.AddChild(NewContextFile(sm))
+
+ // Global settings files (shared across all fids)
+ root.AddChild(NewModelFile(backend))
+ root.AddChild(NewTemperatureFile(backend))
+ root.AddChild(NewSystemFile(backend))
+ root.AddChild(NewThinkingFile(backend))
+ root.AddChild(NewPrefillFile(backend))
+
+ // Token tracking (uses backend's global counters)
+ root.AddChild(NewTokensFile(backend))
+ root.AddChild(NewUsageFile(backend))
+ root.AddChild(NewMetricsFile(backend))
+ root.AddChild(NewCompactFile(backend))
+
+ // Static files
root.AddChild(NewExampleFile())
- root.AddChild(NewUsageFile(client))
- root.AddChild(NewCompactFile(client))
- // Add stream directory
+ // Add stream directory (uses backend directly)
streamDir := protocol.NewStaticDir("stream")
- streamDir.AddChild(NewStreamAskFile(client))
- streamDir.AddChild(NewChunkFile(client))
+ streamDir.AddChild(NewStreamAskFile(backend))
+ streamDir.AddChild(NewChunkFile(backend))
root.AddChild(streamDir)
return root
M internal/protocol/fs.go => internal/protocol/fs.go +16 -0
@@ 36,6 36,22 @@ type Dir interface {
Lookup(name string) (File, error)
}
+// FidAwareFile is implemented by files that need per-fid state.
+// When a file implements this interface, the server will call the
+// fid-aware methods instead of the standard File methods.
+type FidAwareFile interface {
+ File
+
+ // ReadFid reads from the file with fid context
+ ReadFid(fid uint32, p []byte, offset int64) (n int, err error)
+
+ // WriteFid writes to the file with fid context
+ WriteFid(fid uint32, p []byte, offset int64) (n int, err error)
+
+ // CloseFid is called when a fid is clunked
+ CloseFid(fid uint32) error
+}
+
// pathCounter generates unique path IDs for qids
var pathCounter uint64
M internal/protocol/server.go => internal/protocol/server.go +23 -2
@@ 272,7 272,15 @@ func (s *Server) handleRead(state *clientState, payload []byte, buf []byte) ([]b
}
data := make([]byte, count)
- n, err := file.Read(data, int64(msg.Offset))
+ var n int
+
+ // Check for fid-aware file
+ if faf, ok := file.(FidAwareFile); ok {
+ n, err = faf.ReadFid(msg.Fid, data, int64(msg.Offset))
+ } else {
+ n, err = file.Read(data, int64(msg.Offset))
+ }
+
if err != nil && err != io.EOF {
return s.errorResponse(buf, err.Error())
}
@@ 293,7 301,15 @@ func (s *Server) handleWrite(state *clientState, payload []byte, buf []byte) ([]
return s.errorResponse(buf, ErrBadFid.Error())
}
- n, err := file.Write(msg.Data, int64(msg.Offset))
+ var n int
+
+ // Check for fid-aware file
+ if faf, ok := file.(FidAwareFile); ok {
+ n, err = faf.WriteFid(msg.Fid, msg.Data, int64(msg.Offset))
+ } else {
+ n, err = file.Write(msg.Data, int64(msg.Offset))
+ }
+
if err != nil {
return s.errorResponse(buf, err.Error())
}
@@ 314,6 330,11 @@ func (s *Server) handleClunk(state *clientState, payload []byte, buf []byte) ([]
return s.errorResponse(buf, ErrBadFid.Error())
}
+ // Call CloseFid for fid-aware files to clean up per-fid state
+ if faf, ok := file.(FidAwareFile); ok {
+ faf.CloseFid(msg.Fid)
+ }
+
file.Close()
delete(state.fids, msg.Fid)