Skip to content

Instantly share code, notes, and snippets.

@halcwb
Created May 16, 2026 06:37
Show Gist options
  • Select an option

  • Save halcwb/9455dacbdb62ddeda2f6925b0ca281f1 to your computer and use it in GitHub Desktop.

Select an option

Save halcwb/9455dacbdb62ddeda2f6925b0ca281f1 to your computer and use it in GitHub Desktop.
FSharp Harness
// 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