Created
May 16, 2026 06:37
-
-
Save halcwb/9455dacbdb62ddeda2f6925b0ca281f1 to your computer and use it in GitHub Desktop.
FSharp Harness
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // coding-agent.fsx | |
| // Claude-Code-like harness in F#. Two modes from one script: | |
| // default -> in-process REPL agent driven by a local Ollama model via Microsoft.Extensions.AI | |
| // "mcp" arg -> MCP server over stdio exposing the same three tools | |
| // | |
| // Run agent mode: dotnet fsi coding-agent.fsx | |
| // (optional: OLLAMA_HOST, OLLAMA_MODEL) | |
| // Run MCP mode: dotnet fsi coding-agent.fsx mcp | |
| // | |
| // Requires a running Ollama instance with a tool-calling-capable model pulled, e.g. | |
| // ollama pull llama3.2 | |
| #r "nuget: OllamaSharp" | |
| #r "nuget: Microsoft.Extensions.AI" | |
| #r "nuget: AgentNet, *-*" | |
| #r "nuget: FsMcp.Core, *-*" | |
| #r "nuget: FsMcp.Server, *-*" | |
| open System | |
| open System.IO | |
| open System.Threading.Tasks | |
| open OllamaSharp | |
| open Microsoft.Extensions.AI | |
| open AgentNet | |
| open FsMcp.Core | |
| open FsMcp.Server | |
| // --- Path helper ----------------------------------------------------------- | |
| let resolvePath (p: string) = | |
| let expanded = | |
| if p.StartsWith "~" then | |
| let home = Environment.GetFolderPath Environment.SpecialFolder.UserProfile | |
| Path.Combine(home, p.TrimStart('~', '/', '\\')) | |
| else p | |
| if Path.IsPathRooted expanded then expanded | |
| else Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), expanded)) | |
| // --- The three tools ------------------------------------------------------- | |
| // Module-level lets so quotations can capture them by name. | |
| // XML doc tags are read by Tool.createWithDocs to populate the tool schema. | |
| let trace name args = | |
| eprintfn "[tool] %s %s" name args | |
| /// <summary>Read a UTF-8 text file and return its full contents.</summary> | |
| /// <param name="path">Absolute, relative, or ~-prefixed file path.</param> | |
| let readFile (path: string) : string = | |
| trace "read_file" (sprintf "path=%s" path) | |
| File.ReadAllText(resolvePath path) | |
| /// <summary>List files and directories one level under a path.</summary> | |
| /// <param name="path">Directory path. Use "." for the current working directory.</param> | |
| let listFiles (path: string) : string = | |
| trace "list_files" (sprintf "path=%s" path) | |
| let dir = resolvePath path | |
| Directory.EnumerateFileSystemEntries dir | |
| |> Seq.map (fun entry -> | |
| let name = Path.GetFileName entry | |
| if Directory.Exists entry then sprintf "dir\t%s/" name | |
| else sprintf "file\t%s" name) | |
| |> String.concat "\n" | |
| /// <summary>Edit a file. If old_str is empty, create or overwrite the file with new_str. | |
| /// Otherwise replace the first occurrence of old_str with new_str. Errors if old_str is | |
| /// non-empty and not found.</summary> | |
| /// <param name="path">File path.</param> | |
| /// <param name="old_str">Exact substring to replace, or "" to create/overwrite.</param> | |
| /// <param name="new_str">Replacement text, or full file content when old_str is "".</param> | |
| let editFile (path: string) (old_str: string) (new_str: string) : string = | |
| trace "edit_file" (sprintf "path=%s old_str=%A new_str=%A" path old_str new_str) | |
| let full = resolvePath path | |
| let parent = Path.GetDirectoryName full | |
| if not (String.IsNullOrEmpty parent) then | |
| Directory.CreateDirectory parent |> ignore | |
| if old_str = "" then | |
| File.WriteAllText(full, new_str) | |
| sprintf "wrote %d bytes to %s" new_str.Length full | |
| else | |
| let current = File.ReadAllText full | |
| let idx = current.IndexOf old_str | |
| if idx < 0 then | |
| failwithf "old_str not found in %s" full | |
| let edited = | |
| current.Substring(0, idx) | |
| + new_str | |
| + current.Substring(idx + old_str.Length) | |
| File.WriteAllText(full, edited) | |
| sprintf "edited %s" full | |
| // --- Mode A: in-process REPL agent ----------------------------------------- | |
| let envOr (name: string) (fallback: string) = | |
| match Environment.GetEnvironmentVariable name with | |
| | null | "" -> fallback | |
| | v -> v | |
| let buildChatClient () : IChatClient = | |
| let host = envOr "OLLAMA_HOST" "http://localhost:11434" | |
| let model = envOr "OLLAMA_MODEL" "llama3.2:latest" | |
| eprintfn "ollama: %s, model: %s" host model | |
| (new OllamaApiClient(host, model) :> IChatClient) | |
| .AsBuilder() | |
| .UseFunctionInvocation() | |
| .Build() | |
| let systemPrompt = """ | |
| You are a coding assistant operating inside a small F# harness. | |
| You have three tools: read_file, list_files, edit_file. | |
| Convention: edit_file with empty old_str creates or overwrites the file with new_str. | |
| Inspect files before editing them. When the task is complete, reply in plain text | |
| without calling further tools. | |
| """ | |
| let runAgent () = task { | |
| let chatClient = buildChatClient () | |
| // Tool.createWithDocs reads XML doc tags, but dotnet fsi does not emit an | |
| // XML doc file for scripts, so descriptions come back empty. Build each | |
| // tool from the quotation and attach a description manually. | |
| let agent = | |
| ChatAgent.create systemPrompt | |
| |> ChatAgent.withName "coding-agent" | |
| |> ChatAgent.withTools [ | |
| Tool.create <@ readFile @> | |
| |> Tool.describe "Read a UTF-8 text file and return its full contents." | |
| Tool.create <@ listFiles @> | |
| |> Tool.describe "List files and directories one level under a path." | |
| Tool.create <@ editFile @> | |
| |> Tool.describe "Edit a file. If old_str is empty, create or overwrite with new_str. Otherwise replace the first occurrence of old_str with new_str." | |
| ] | |
| |> ChatAgent.build chatClient | |
| eprintfn "coding-agent ready. Ctrl-D (or empty EOF) to quit." | |
| let mutable running = true | |
| while running do | |
| eprintf "> " | |
| match Console.ReadLine() with | |
| | null -> | |
| running <- false | |
| | line when String.IsNullOrWhiteSpace line -> | |
| () | |
| | line -> | |
| let! reply = agent.Chat(line) | |
| printfn "%s" reply | |
| } | |
| // --- Mode B: MCP server over stdio ----------------------------------------- | |
| // stdout is reserved for JSON-RPC traffic. Any diagnostics must go to stderr. | |
| type private ReadArgs = { path: string } | |
| type private ListArgs = { path: string } | |
| type private EditArgs = { path: string; old_str: string; new_str: string } | |
| let runMcp () = | |
| let server = | |
| mcpServer { | |
| name "fsharness-coding-agent" | |
| version "0.1.0" | |
| tool (TypedTool.define<ReadArgs> "read_file" | |
| "Read a UTF-8 text file and return its full contents." | |
| (fun a -> task { return Ok [ Content.text (readFile a.path) ] }) | |
| |> unwrapResult) | |
| tool (TypedTool.define<ListArgs> "list_files" | |
| "List files and directories one level under a path." | |
| (fun a -> task { return Ok [ Content.text (listFiles a.path) ] }) | |
| |> unwrapResult) | |
| tool (TypedTool.define<EditArgs> "edit_file" | |
| "Replace the first occurrence of old_str with new_str. Empty old_str creates or overwrites." | |
| (fun a -> task { return Ok [ Content.text (editFile a.path a.old_str a.new_str) ] }) | |
| |> unwrapResult) | |
| useStdio | |
| } | |
| (Server.run server).GetAwaiter().GetResult() | |
| // --- Dispatch -------------------------------------------------------------- | |
| match fsi.CommandLineArgs |> Array.tryItem 1 with | |
| | Some "mcp" -> | |
| runMcp () | |
| | _ -> | |
| (runAgent ()).GetAwaiter().GetResult() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment