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
+}