From 0afa911aad2ddf1b824c5e829bd12541e622e09e Mon Sep 17 00:00:00 2001 From: pdfinn Date: Sun, 22 Feb 2026 15:39:55 +0800 Subject: [PATCH] feat(llmfs): native Anthropic tool_use protocol support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:::\n" or "STOP:end_turn\n" 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\n\n---\n..." session_dir.go: add tools file to Children() and Lookup() Co-Authored-By: Claude Sonnet 4.6 --- internal/llm/backend.go | 36 ++++- internal/llm/cli_client.go | 15 +- internal/llm/client.go | 258 +++++++++++++++++++++++++------- internal/llm/session.go | 142 ++++++++++++++++-- internal/llmfs/session_ask.go | 89 ++++++++++- internal/llmfs/session_dir.go | 3 + internal/llmfs/session_tools.go | 61 ++++++++ 7 files changed, 528 insertions(+), 76 deletions(-) create mode 100644 internal/llmfs/session_tools.go diff --git a/internal/llm/backend.go b/internal/llm/backend.go index 898634b90f2b144150b2f7032dd5efa333387972..791d1fe954e42944714060d52f10d058faaa61c0 100644 --- a/internal/llm/backend.go +++ b/internal/llm/backend.go @@ -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" or + // "STOP:tool_use\nTOOL:::\n..." + // 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 diff --git a/internal/llm/cli_client.go b/internal/llm/cli_client.go index 1139f43fac562a11645222e5f7033d80eb0532e2..17c06ea4469f5fccc8214712dec3682bddde4ff2 100644 --- a/internal/llm/cli_client.go +++ b/internal/llm/cli_client.go @@ -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 } diff --git a/internal/llm/client.go b/internal/llm/client.go index d7457e06df34833dbf327bea64c331c7f868b450..fece21f986e4daba30f83b1a42ec2ed9cf107c40 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -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 } diff --git a/internal/llm/session.go b/internal/llm/session.go index eb8a27de3d51596e19d1de5b05dffb85503db1f0..ecfb1298bcfd6a88f0962eb3e6ebed14ec143168 100644 --- a/internal/llm/session.go +++ b/internal/llm/session.go @@ -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 diff --git a/internal/llmfs/session_ask.go b/internal/llmfs/session_ask.go index 252386d48ed119020dc95623462506c30b90f3fa..a00b575cc2e4ae4f7b4fd8627dae6ea2aaa509a3 100644 --- a/internal/llmfs/session_ask.go +++ b/internal/llmfs/session_ask.go @@ -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 +// +// +// --- +// +// +// --- 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() diff --git a/internal/llmfs/session_dir.go b/internal/llmfs/session_dir.go index 0e70d98bf8dc3f17191a21493232de762ddc8557..b84e8a92a6289c2a0eeafa094ca51e719cf752e5 100644 --- a/internal/llmfs/session_dir.go +++ b/internal/llmfs/session_dir.go @@ -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: diff --git a/internal/llmfs/session_tools.go b/internal/llmfs/session_tools.go new file mode 100644 index 0000000000000000000000000000000000000000..2327303e3f0c943e7ec1ff862f49c55908775a43 --- /dev/null +++ b/internal/llmfs/session_tools.go @@ -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 +}