~kris/9p

llm9p

0afa911aad2ddf1b824c5e829bd12541e622e09e — pdfinn 6 months ago f2d8604
feat(llmfs): native Anthropic tool_use protocol support

Add structured tool_use protocol to llm9p, enabling Veltro to use
Claude's native JSON tool invocation instead of text-based parsing.

New types (backend.go):
- AskResponse: carries Response, StructuredJSON, and Tokens — replaces
  the old (string, int, error) return from AskWithRequest
- ToolDef: tool definition passed to Anthropic tools API
- ToolResult: tool execution result for submission to the LLM
- Backend.AskWithRequest() now returns (AskResponse, error)

client.go:
- Message.StructuredContent: stores JSON content blocks for correct
  history replay of tool_use and tool_result turns
- AskWithRequest(): when ToolDefs non-nil, passes tools to API and
  returns STOP:/TOOL: formatted response for Limbo parsing
  Format: "STOP:tool_use\nTOOL:<id>:<name>:<args>\n<text>" or
          "STOP:end_turn\n<text>" or plain text (no tools)
- AskWithToolResults(): submits tool results as a new user turn
- Helpers: buildMessageParam(), buildToolParams(), extractToolArgs(),
  jsonEscapeString()

session.go:
- Session.tools field + SetTools/Tools methods
- Session.AddStructuredMessage() for storing structured content blocks
- AskRequest extended with ToolDefs and ToolResults fields
- SessionManager.Ask(): includes tools, stores structured JSON in history
- SessionManager.AskWithToolResults(): new method for tool result turns
- Fix Compact() for new AskResponse return type
- Helpers: extractTextContent(), buildToolResultsJSON()

cli_client.go: update AskWithRequest() to return AskResponse (no tools
support; StructuredJSON always empty)

session_tools.go (new): /n/llm/{id}/tools write-only file
- Write JSON array of ToolDef to enable native tool_use protocol
- Empty write clears tools (returns session to text-only mode)

session_ask.go:
- Detect TOOL_RESULTS\n prefix in Write() → parseToolResults() → AskWithToolResults()
- TOOL_RESULTS format: "TOOL_RESULTS\n<id>\n<content>\n---\n..."

session_dir.go: add tools file to Children() and Lookup()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
M internal/llm/backend.go => internal/llm/backend.go +34 -2
@@ 3,6 3,38 @@ package llm

import "context"

// AskResponse is returned by AskWithRequest.
// It carries the formatted 9P response string, optional structured JSON for
// session history replay (non-empty only when tools are active), and token count.
type AskResponse struct {
	// Response is the formatted string written to session.lastResponse.
	// When tools are defined: "STOP:end_turn\n<text>" or
	//   "STOP:tool_use\nTOOL:<id>:<name>:<args>\n...<text>"
	// When no tools: plain response text (backward-compatible).
	Response string

	// StructuredJSON is a JSON array of content blocks for session history.
	// Non-empty only when the response contains tool_use blocks.
	// Stored in Message.StructuredContent for correct API replay.
	StructuredJSON string

	// Tokens is the total token count (input + output) for this turn.
	Tokens int
}

// ToolDef is a tool definition passed to the Anthropic tools API.
type ToolDef struct {
	Name        string                 `json:"name"`
	Description string                 `json:"description"`
	InputSchema map[string]interface{} `json:"input_schema"`
}

// ToolResult is a tool execution result submitted back to the LLM.
type ToolResult struct {
	ToolUseID string
	Content   string
}

// Backend defines the interface for LLM backends.
// Both API and CLI clients implement this interface.
type Backend interface {


@@ 49,9 81,9 @@ type Backend interface {
	// 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)
	// AskWithRequest sends a prompt with all settings from the request (CSP - no client state)
	// AskWithRequest sends a prompt with all settings from the request (CSP - no client state).
	// This is the primary method for the clone-based session architecture.
	AskWithRequest(ctx context.Context, req AskRequest) (string, int, error)
	AskWithRequest(ctx context.Context, req AskRequest) (AskResponse, 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 +9 -6
@@ 674,7 674,8 @@ func (c *CLIClient) AskWithHistory(ctx context.Context, history []Message, promp
// AskWithRequest sends a prompt with all settings from the request (CSP - no client state).
// This is the primary method for the clone-based session architecture.
// All settings come from the request parameter, making this a stateless API call.
func (c *CLIClient) AskWithRequest(ctx context.Context, req AskRequest) (string, int, error) {
// Note: CLIClient does not support native tool_use protocol; StructuredJSON is always empty.
func (c *CLIClient) AskWithRequest(ctx context.Context, req AskRequest) (AskResponse, error) {
	// Build prompt from provided history
	var parts []string
	var systemParts []string


@@ 695,8 696,10 @@ func (c *CLIClient) AskWithRequest(ctx context.Context, req AskRequest) (string,
		}
	}

	// Add the new user prompt
	parts = append(parts, fmt.Sprintf("Human: %s", req.Prompt))
	// Add the new user prompt (may be empty for tool result turns — CLI doesn't support these)
	if req.Prompt != "" {
		parts = append(parts, fmt.Sprintf("Human: %s", req.Prompt))
	}

	fullPrompt := strings.Join(parts, "\n\n")
	systemPrompt := strings.Join(systemParts, "\n\n")


@@ 743,13 746,13 @@ func (c *CLIClient) AskWithRequest(ctx context.Context, req AskRequest) (string,
	cmd.Stderr = &stderr

	if err := cmd.Run(); err != nil {
		return "", 0, fmt.Errorf("claude CLI error: %w (stderr: %s)", err, stderr.String())
		return AskResponse{}, 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)
		return AskResponse{}, fmt.Errorf("failed to parse CLI response: %w", err)
	}

	// Prepend prefill to response to keep model in character


@@ 761,5 764,5 @@ func (c *CLIClient) AskWithRequest(ctx context.Context, req AskRequest) (string,
	// Estimate tokens: prompt + response (chars / 4)
	tokens := estimateTokens(fullPrompt) + estimateTokens(responseText)

	return responseText, tokens, nil
	return AskResponse{Response: responseText, Tokens: tokens}, nil
}

M internal/llm/client.go => internal/llm/client.go +204 -54
@@ 13,10 13,13 @@ import (
	"github.com/anthropics/anthropic-sdk-go/option"
)

// Message represents a single message in a conversation
// Message represents a single message in a conversation.
// StructuredContent, when non-empty, holds a JSON array of content blocks
// for proper API replay of tool-use turns. Plain-text turns leave it empty.
type Message struct {
	Role    string `json:"role"`    // "user" or "assistant"
	Content string `json:"content"` // message content
	Role              string `json:"role"`          // "user" or "assistant"
	Content           string `json:"content"`       // text content (always set)
	StructuredContent string `json:"sc,omitempty"`  // JSON content blocks (tool turns only)
}

// MetricsCallback is called after each LLM request with performance data


@@ 615,49 618,49 @@ func (c *Client) AskWithHistory(ctx context.Context, history []Message, prompt s

// AskWithRequest sends a prompt with all settings from the request (CSP - no client state).
// This is the primary method for the clone-based session architecture.
// All settings come from the request parameter, making this a stateless API call.
func (c *Client) AskWithRequest(ctx context.Context, req AskRequest) (string, int, error) {
	// Build API messages from provided history plus the new prompt
// When req.ToolDefs is non-nil, uses the Anthropic native tool_use protocol and
// returns a STOP:-prefixed response. Otherwise returns plain text (backward-compatible).
func (c *Client) AskWithRequest(ctx context.Context, req AskRequest) (AskResponse, error) {
	// Build API messages from provided history
	apiMessages := make([]anthropic.MessageParam, 0, len(req.Messages)+2)
	var systemBlocks []anthropic.TextBlockParam

	// Add system prompt from request
	if req.SystemPrompt != "" {
		systemBlocks = append(systemBlocks, anthropic.TextBlockParam{
			Text: req.SystemPrompt,
		})
		systemBlocks = append(systemBlocks, anthropic.TextBlockParam{Text: req.SystemPrompt})
	}

	for _, msg := range req.Messages {
		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),
			))
			systemBlocks = append(systemBlocks, anthropic.TextBlockParam{Text: msg.Content})
		case "user", "assistant":
			param := buildMessageParam(msg)
			apiMessages = append(apiMessages, param)
		}
	}

	// Add the new user prompt
	apiMessages = append(apiMessages, anthropic.NewUserMessage(
		anthropic.NewTextBlock(req.Prompt),
	))
	// Add new user turn: either a text prompt or tool results.
	if len(req.ToolResults) > 0 {
		// Tool results ARE the new user turn — build tool_result content blocks.
		var content []anthropic.ContentBlockParamUnion
		for _, r := range req.ToolResults {
			content = append(content, anthropic.NewToolResultBlock(r.ToolUseID, r.Content, false))
		}
		apiMessages = append(apiMessages, anthropic.MessageParam{
			Role:    anthropic.MessageParamRoleUser,
			Content: content,
		})
	} else if req.Prompt != "" {
		apiMessages = append(apiMessages, anthropic.NewUserMessage(anthropic.NewTextBlock(req.Prompt)))
	}

	// Add prefill as partial assistant message to keep model in character
	if req.Prefill != "" {
	// Prefill only when not in tool mode (prefill is inappropriate mid-tool-loop).
	if req.Prefill != "" && len(req.ToolDefs) == 0 {
		apiMessages = append(apiMessages, anthropic.NewAssistantMessage(
			anthropic.NewTextBlock(req.Prefill),
		))
	}

	// Use model from request, or fall back to client default
	model := req.Model
	if model == "" {
		c.mu.RLock()


@@ 665,51 668,198 @@ func (c *Client) AskWithRequest(ctx context.Context, req AskRequest) (string, in
		c.mu.RUnlock()
	}

	// Use temperature from request
	temp := req.Temperature

	// Build request params
	params := anthropic.MessageNewParams{
		Model:       anthropic.Model(model),
		MaxTokens:   4096,
		Messages:    apiMessages,
		Temperature: anthropic.Float(temp),
		Temperature: anthropic.Float(req.Temperature),
	}

	// Add system prompt if present
	if len(systemBlocks) > 0 {
		params.System = systemBlocks
	}

	// Make the API call with timing
	// Attach tool definitions when present.
	if len(req.ToolDefs) > 0 {
		params.Tools = buildToolParams(req.ToolDefs)
		params.ToolChoice = anthropic.ToolChoiceUnionParam{
			OfToolChoiceAuto: &anthropic.ToolChoiceAutoParam{},
		}
	}

	startTime := time.Now()
	response, err := c.client.Messages.New(ctx, params)
	resp, err := c.client.Messages.New(ctx, params)
	latencyMs := time.Since(startTime).Milliseconds()

	if err != nil {
		return "", 0, fmt.Errorf("API error: %w", err)
		return AskResponse{}, fmt.Errorf("API error: %w", err)
	}

	// Extract response text
	var responseText string
	for _, block := range response.Content {
		if block.Type == "text" {
			responseText += block.Text
	tokens := int(resp.Usage.InputTokens + resp.Usage.OutputTokens)
	RecordMetrics(int(resp.Usage.InputTokens), int(resp.Usage.OutputTokens), latencyMs)

	// Plain-text mode (no tools): return text as before.
	if len(req.ToolDefs) == 0 {
		var text string
		for _, block := range resp.Content {
			if block.Type == "text" {
				text += block.Text
			}
		}
		if req.Prefill != "" && !strings.HasPrefix(text, req.Prefill) {
			text = req.Prefill + text
		}
		return AskResponse{Response: text, Tokens: tokens}, nil
	}

	// Prepend prefill to response (it was used as partial assistant message)
	// Only if response doesn't already start with it (model may echo from history)
	if req.Prefill != "" && !strings.HasPrefix(responseText, req.Prefill) {
		responseText = req.Prefill + responseText
	// Tool mode: format STOP: response and build structured JSON for history.
	var textParts []string
	var toolCalls []struct{ id, name, args string }
	var structBlocks []string // JSON content blocks for history

	for _, block := range resp.Content {
		switch block.Type {
		case "text":
			if block.Text != "" {
				textParts = append(textParts, block.Text)
				escaped := jsonEscapeString(block.Text)
				structBlocks = append(structBlocks, fmt.Sprintf(`{"type":"text","text":"%s"}`, escaped))
			}
		case "tool_use":
			tb := block.AsResponseToolUseBlock()
			args := extractToolArgs(tb.Input)
			toolCalls = append(toolCalls, struct{ id, name, args string }{tb.ID, tb.Name, args})
			inputJSON := string(tb.Input)
			if inputJSON == "" {
				inputJSON = "{}"
			}
			idEsc := jsonEscapeString(tb.ID)
			nameEsc := jsonEscapeString(tb.Name)
			structBlocks = append(structBlocks,
				fmt.Sprintf(`{"type":"tool_use","id":"%s","name":"%s","input":%s}`, idEsc, nameEsc, inputJSON))
		}
	}

	tokens := int(response.Usage.InputTokens + response.Usage.OutputTokens)
	structuredJSON := ""
	if len(structBlocks) > 0 {
		structuredJSON = "[" + strings.Join(structBlocks, ",") + "]"
	}

	// Record metrics
	inputToks := int(response.Usage.InputTokens)
	outputToks := int(response.Usage.OutputTokens)
	RecordMetrics(inputToks, outputToks, latencyMs)
	// Build formatted response.
	var sb strings.Builder
	if resp.StopReason == anthropic.MessageStopReasonToolUse {
		sb.WriteString("STOP:tool_use\n")
		for _, tc := range toolCalls {
			// Escape newlines in args so the TOOL: line stays single-line.
			safeArgs := strings.ReplaceAll(tc.args, "\n", `\n`)
			sb.WriteString(fmt.Sprintf("TOOL:%s:%s:%s\n", tc.id, tc.name, safeArgs))
		}
	} else {
		sb.WriteString("STOP:end_turn\n")
	}
	sb.WriteString(strings.Join(textParts, ""))

	return responseText, tokens, nil
	return AskResponse{Response: sb.String(), StructuredJSON: structuredJSON, Tokens: tokens}, nil
}

// buildMessageParam converts a Message to an anthropic.MessageParam.
// When StructuredContent is set, the full content blocks are rebuilt for API replay.
func buildMessageParam(msg Message) anthropic.MessageParam {
	role := anthropic.MessageParamRoleUser
	if msg.Role == "assistant" {
		role = anthropic.MessageParamRoleAssistant
	}

	if msg.StructuredContent == "" {
		// Plain text message.
		if role == anthropic.MessageParamRoleUser {
			return anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content))
		}
		return anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content))
	}

	// Structured content: unmarshal and rebuild content blocks.
	type rawBlock struct {
		Type      string          `json:"type"`
		Text      string          `json:"text"`
		ID        string          `json:"id"`
		Name      string          `json:"name"`
		Input     json.RawMessage `json:"input"`
		ToolUseID string          `json:"tool_use_id"`
		Content   string          `json:"content"`
		IsError   bool            `json:"is_error"`
	}
	var blocks []rawBlock
	if err := json.Unmarshal([]byte(msg.StructuredContent), &blocks); err != nil {
		// Fallback to plain text on parse error.
		if role == anthropic.MessageParamRoleUser {
			return anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content))
		}
		return anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content))
	}

	var content []anthropic.ContentBlockParamUnion
	for _, b := range blocks {
		switch b.Type {
		case "text":
			content = append(content, anthropic.ContentBlockParamOfRequestTextBlock(b.Text))
		case "tool_use":
			var input interface{} = b.Input
			if len(b.Input) == 0 {
				input = map[string]interface{}{}
			}
			content = append(content, anthropic.ContentBlockParamOfRequestToolUseBlock(b.ID, input, b.Name))
		case "tool_result":
			content = append(content, anthropic.NewToolResultBlock(b.ToolUseID, b.Content, b.IsError))
		}
	}

	return anthropic.MessageParam{Role: role, Content: content}
}

// buildToolParams converts ToolDef slice to anthropic SDK tool params.
func buildToolParams(defs []ToolDef) []anthropic.ToolUnionParam {
	out := make([]anthropic.ToolUnionParam, 0, len(defs))
	for _, d := range defs {
		schema := anthropic.ToolInputSchemaParam{
			Properties: d.InputSchema["properties"],
		}
		desc := d.Description
		out = append(out, anthropic.ToolUnionParam{
			OfTool: &anthropic.ToolParam{
				Name:        d.Name,
				Description: anthropic.String(desc),
				InputSchema: schema,
			},
		})
	}
	return out
}

// extractToolArgs extracts the "args" string from a tool_use input JSON.
// Falls back to the raw JSON string if "args" is not present.
func extractToolArgs(input json.RawMessage) string {
	var m map[string]interface{}
	if err := json.Unmarshal(input, &m); err != nil {
		return string(input)
	}
	if args, ok := m["args"].(string); ok {
		return args
	}
	// No "args" key — join all string values as fallback.
	var parts []string
	for _, v := range m {
		if s, ok := v.(string); ok {
			parts = append(parts, s)
		}
	}
	return strings.Join(parts, " ")
}

// jsonEscapeString escapes a string for embedding in a JSON string literal.
func jsonEscapeString(s string) string {
	s = strings.ReplaceAll(s, `\`, `\\`)
	s = strings.ReplaceAll(s, `"`, `\"`)
	s = strings.ReplaceAll(s, "\n", `\n`)
	s = strings.ReplaceAll(s, "\r", `\r`)
	s = strings.ReplaceAll(s, "\t", `\t`)
	return s
}

M internal/llm/session.go => internal/llm/session.go +132 -10
@@ 44,6 44,7 @@ type Session struct {
	systemPrompt   string
	thinkingTokens int
	prefill        string
	tools          []ToolDef // native tool definitions (nil = text-only mode)

	mu     sync.RWMutex
	closed bool


@@ 207,6 208,37 @@ func (s *Session) SetPrefill(prefill string) {
	s.prefill = prefill
}

// Tools returns the session's tool definitions (nil = text-only mode).
func (s *Session) Tools() []ToolDef {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if s.tools == nil {
		return nil
	}
	out := make([]ToolDef, len(s.tools))
	copy(out, s.tools)
	return out
}

// SetTools sets the tool definitions for this session.
// After setting, subsequent Ask calls will use native tool_use protocol.
func (s *Session) SetTools(tools []ToolDef) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.tools = tools
}

// AddStructuredMessage appends a message with optional structured content.
func (s *Session) AddStructuredMessage(role, content, structuredJSON string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.messages = append(s.messages, Message{
		Role:              role,
		Content:           content,
		StructuredContent: structuredJSON,
	})
}

// IsClosed returns whether the session has been closed.
func (s *Session) IsClosed() bool {
	s.mu.RLock()


@@ 320,22 352,110 @@ func (sm *SessionManager) Ask(ctx context.Context, id int, prompt string) (strin
		SystemPrompt:   systemPrompt,
		ThinkingTokens: thinkingTokens,
		Prefill:        prefill,
		ToolDefs:       session.Tools(),
	}

	// Make API call (stateless)
	response, tokens, err := sm.apiClient.AskWithRequest(ctx, req)
	ar, err := sm.apiClient.AskWithRequest(ctx, req)
	if err != nil {
		session.SetLastResponse("Error: " + err.Error())
		return "", err
	}

	// Update session state
	// Update session state — store structured content for tool turns
	textContent := extractTextContent(ar.Response)
	session.AddMessage("user", prompt)
	session.AddMessage("assistant", response)
	session.AddTokens(tokens)
	session.SetLastResponse(response)
	session.AddStructuredMessage("assistant", textContent, ar.StructuredJSON)
	session.AddTokens(ar.Tokens)
	session.SetLastResponse(ar.Response)

	return response, nil
	return ar.Response, nil
}

// AskWithToolResults submits tool execution results as a user turn and gets
// the next assistant response. Called after parsing TOOL_RESULTS from the ask file.
func (sm *SessionManager) AskWithToolResults(ctx context.Context, id int, results []ToolResult) (string, error) {
	session := sm.Get(id)
	if session == nil {
		return "", ErrSessionNotFound
	}
	if session.IsClosed() {
		return "", ErrSessionClosed
	}

	session.mu.RLock()
	history := make([]Message, len(session.messages))
	copy(history, session.messages)
	model := session.model
	temperature := session.temperature
	systemPrompt := session.systemPrompt
	thinkingTokens := session.thinkingTokens
	session.mu.RUnlock()

	req := AskRequest{
		Messages:       history,
		Model:          model,
		Temperature:    temperature,
		SystemPrompt:   systemPrompt,
		ThinkingTokens: thinkingTokens,
		ToolDefs:       session.Tools(),
		ToolResults:    results,
		// Prompt is intentionally empty — tool results ARE the new user turn.
		// Prefill is intentionally empty — prefill is inappropriate mid-tool-loop.
	}

	ar, err := sm.apiClient.AskWithRequest(ctx, req)
	if err != nil {
		session.SetLastResponse("Error: " + err.Error())
		return "", err
	}

	// Store tool_result user turn + assistant response in history
	toolResultsText := fmt.Sprintf("tool results: %d results submitted", len(results))
	toolResultsJSON := buildToolResultsJSON(results)
	session.AddStructuredMessage("user", toolResultsText, toolResultsJSON)
	textContent := extractTextContent(ar.Response)
	session.AddStructuredMessage("assistant", textContent, ar.StructuredJSON)
	session.AddTokens(ar.Tokens)
	session.SetLastResponse(ar.Response)

	return ar.Response, nil
}

// extractTextContent extracts the plain text from a STOP:-formatted response.
// For plain-text (no-tools) responses, returns the response as-is.
func extractTextContent(response string) string {
	if !strings.HasPrefix(response, "STOP:") {
		return response
	}
	// Skip STOP: line and TOOL: lines, return the text portion
	lines := strings.SplitN(response, "\n", -1)
	var text []string
	for _, line := range lines {
		if strings.HasPrefix(line, "STOP:") || strings.HasPrefix(line, "TOOL:") {
			continue
		}
		text = append(text, line)
	}
	return strings.Join(text, "\n")
}

// buildToolResultsJSON builds the JSON content blocks for a tool_results user turn.
// Stored in Message.StructuredContent for proper API replay.
func buildToolResultsJSON(results []ToolResult) string {
	if len(results) == 0 {
		return ""
	}
	var parts []string
	for _, r := range results {
		content := strings.ReplaceAll(r.Content, `\`, `\\`)
		content = strings.ReplaceAll(content, `"`, `\"`)
		content = strings.ReplaceAll(content, "\n", `\n`)
		content = strings.ReplaceAll(content, "\r", `\r`)
		toolUseID := strings.ReplaceAll(r.ToolUseID, `"`, `\"`)
		parts = append(parts, fmt.Sprintf(`{"type":"tool_result","tool_use_id":"%s","content":"%s"}`, toolUseID, content))
	}
	return "[" + strings.Join(parts, ",") + "]"
}

// ListSessions returns the IDs of all active sessions.


@@ 402,7 522,7 @@ func (sm *SessionManager) Compact(ctx context.Context, id int) error {
		Temperature: 0.3,
	}

	summary, tokens, err := sm.apiClient.AskWithRequest(ctx, req)
	ar, err := sm.apiClient.AskWithRequest(ctx, req)
	if err != nil {
		return fmt.Errorf("compaction LLM call failed: %w", err)
	}


@@ 410,10 530,10 @@ func (sm *SessionManager) Compact(ctx context.Context, id int) error {
	// Replace history with a minimal exchange conveying the summary
	session.mu.Lock()
	session.messages = []Message{
		{Role: "user", Content: "Context from earlier in this session:\n" + summary},
		{Role: "user", Content: "Context from earlier in this session:\n" + ar.Response},
		{Role: "assistant", Content: "Understood. I have the context from our previous work and will continue from there."},
	}
	session.totalTokens = tokens
	session.totalTokens = ar.Tokens
	session.mu.Unlock()

	return nil


@@ 422,12 542,14 @@ func (sm *SessionManager) Compact(ctx context.Context, id int) error {
// AskRequest contains all parameters for an API call.
type AskRequest struct {
	Messages       []Message
	Prompt         string
	Prompt         string  // empty when ToolResults is set (tool_results IS the new user turn)
	Model          string
	Temperature    float64
	SystemPrompt   string
	ThinkingTokens int
	Prefill        string
	ToolDefs       []ToolDef    // non-nil enables native tool_use protocol
	ToolResults    []ToolResult // non-nil: submit tool results as a new user turn
}

// Errors

M internal/llmfs/session_ask.go => internal/llmfs/session_ask.go +85 -4
@@ 2,6 2,7 @@ package llmfs

import (
	"context"
	"fmt"
	"io"
	"log"
	"strings"


@@ 48,6 49,18 @@ func (f *SessionAskFile) Read(p []byte, offset int64) (int, error) {
}

// Write sends a prompt to the LLM using this session's settings.
// If the write begins with "TOOL_RESULTS\n", it is parsed as tool execution
// results and submitted via AskWithToolResults instead of a plain Ask.
//
// TOOL_RESULTS format:
//
//	TOOL_RESULTS
//	<tool_use_id>
//	<result content (may be multi-line)>
//	---
//	<tool_use_id2>
//	<result content>
//	---
func (f *SessionAskFile) Write(p []byte, offset int64) (int, error) {
	log.Printf("llm9p: SessionAskFile.Write session=%d len=%d", f.id, len(p))



@@ 56,20 69,88 @@ func (f *SessionAskFile) Write(p []byte, offset int64) (int, error) {
		return len(p), nil // Empty write is a no-op
	}

	log.Printf("llm9p: SessionAskFile.Write prompt: %s", prompt[:min(len(prompt), 50)])

	ctx := context.Background()

	// Detect TOOL_RESULTS prefix → submit tool results, not a plain prompt
	if strings.HasPrefix(prompt, "TOOL_RESULTS\n") {
		results, err := parseToolResults(prompt)
		if err != nil {
			log.Printf("llm9p: SessionAskFile.Write TOOL_RESULTS parse error: %v", err)
			// Store error in session so client can read it back
			session := f.sm.Get(f.id)
			if session != nil {
				session.SetLastResponse("Error: " + err.Error())
			}
			return len(p), nil
		}
		log.Printf("llm9p: SessionAskFile.Write submitting %d tool results", len(results))
		_, err = f.sm.AskWithToolResults(ctx, f.id, results)
		if err != nil {
			log.Printf("llm9p: SessionAskFile.Write tool results error: %v", err)
		}
		return len(p), nil
	}

	// Regular text prompt
	log.Printf("llm9p: SessionAskFile.Write prompt: %s", prompt[:min(len(prompt), 50)])
	response, err := f.sm.Ask(ctx, f.id, prompt)
	if err != nil {
		log.Printf("llm9p: SessionAskFile.Write error: %v", err)
		// Error is stored in session.LastResponse by SessionManager
		return len(p), nil // Return success so client knows write completed
		return len(p), nil
	}

	log.Printf("llm9p: SessionAskFile.Write success, response len=%d", len(response))
	return len(p), nil
}

// parseToolResults parses the TOOL_RESULTS wire format into a slice of ToolResult.
// Each result block starts with a tool_use_id line, followed by content lines,
// terminated by "---" (or end of input).
func parseToolResults(text string) ([]llm.ToolResult, error) {
	lines := strings.Split(text, "\n")
	if len(lines) < 2 || lines[0] != "TOOL_RESULTS" {
		return nil, fmt.Errorf("missing TOOL_RESULTS header")
	}

	var results []llm.ToolResult
	i := 1 // Skip "TOOL_RESULTS" header

	for i < len(lines) {
		// Skip blank lines between blocks
		if strings.TrimSpace(lines[i]) == "" {
			i++
			continue
		}

		// Next non-empty line is the tool_use_id
		toolUseID := strings.TrimSpace(lines[i])
		i++

		// Collect content lines until "---" separator or end
		var contentLines []string
		for i < len(lines) && lines[i] != "---" {
			contentLines = append(contentLines, lines[i])
			i++
		}
		// Skip "---" separator
		if i < len(lines) && lines[i] == "---" {
			i++
		}

		content := strings.TrimRight(strings.Join(contentLines, "\n"), "\n")
		results = append(results, llm.ToolResult{
			ToolUseID: toolUseID,
			Content:   content,
		})
	}

	if len(results) == 0 {
		return nil, fmt.Errorf("TOOL_RESULTS contained no results")
	}

	return results, nil
}

// Stat returns the file's metadata.
func (f *SessionAskFile) Stat() protocol.Stat {
	s := f.BaseFile.Stat()

M internal/llmfs/session_dir.go => internal/llmfs/session_dir.go +3 -0
@@ 43,6 43,7 @@ func (d *SessionDir) Children() []protocol.File {
		NewSessionSystemFile(d.sm, d.id),
		NewSessionThinkingFile(d.sm, d.id),
		NewSessionPrefillFile(d.sm, d.id),
		NewSessionToolsFile(d.sm, d.id),
		NewSessionUsageFile(d.sm, d.id),
	}
}


@@ 73,6 74,8 @@ func (d *SessionDir) Lookup(name string) (protocol.File, error) {
		return NewSessionThinkingFile(d.sm, d.id), nil
	case "prefill":
		return NewSessionPrefillFile(d.sm, d.id), nil
	case "tools":
		return NewSessionToolsFile(d.sm, d.id), nil
	case "usage":
		return NewSessionUsageFile(d.sm, d.id), nil
	default:

A internal/llmfs/session_tools.go => internal/llmfs/session_tools.go +61 -0
@@ 0,0 1,61 @@
package llmfs

import (
	"encoding/json"
	"fmt"
	"log"
	"strings"

	"github.com/NERVsystems/llm9p/internal/llm"
	"github.com/NERVsystems/llm9p/internal/protocol"
)

// SessionToolsFile is the tools file for a specific session: /n/llm/N/tools
// Write a JSON array of tool definitions to enable native tool_use protocol.
// Write an empty string to clear tools and return to text-only mode.
//
// Tool definition format (JSON array):
//
//	[{"name":"toolname","description":"what it does","input_schema":{"type":"object","properties":{"args":{"type":"string"}},"required":["args"]}}]
type SessionToolsFile struct {
	*protocol.BaseFile
	sm *llm.SessionManager
	id int
}

// NewSessionToolsFile creates a tools file for the given session.
func NewSessionToolsFile(sm *llm.SessionManager, id int) *SessionToolsFile {
	return &SessionToolsFile{
		BaseFile: protocol.NewBaseFile("tools", 0222), // write-only
		sm:       sm,
		id:       id,
	}
}

// Write accepts a JSON array of tool definitions and installs them on the session.
// An empty write clears all tools, returning the session to text-only mode.
func (f *SessionToolsFile) Write(p []byte, offset int64) (int, error) {
	content := strings.TrimSpace(string(p))

	session := f.sm.Get(f.id)
	if session == nil {
		return 0, protocol.ErrNotFound
	}

	if content == "" {
		// Empty write → clear tools (text-only mode)
		session.SetTools(nil)
		log.Printf("llm9p: SessionToolsFile.Write session=%d cleared tools", f.id)
		return len(p), nil
	}

	var tools []llm.ToolDef
	if err := json.Unmarshal([]byte(content), &tools); err != nil {
		log.Printf("llm9p: SessionToolsFile.Write parse error: %v", err)
		return 0, fmt.Errorf("tools: invalid JSON: %v", err)
	}

	session.SetTools(tools)
	log.Printf("llm9p: SessionToolsFile.Write session=%d installed %d tools", f.id, len(tools))
	return len(p), nil
}