Fathom the depths, surface the answer.
A small, dependency-free Swift agent SDK for DeepSeek-style (OpenAI-compatible) models.
Define an Agent — a model + system prompt + tools + policies — and it drives a full
tool-calling loop to an answer. Not just an API wrapper: it ships the machinery a real
agent needs.
What you get
Agent+Thread— a reusable agent, and stateful multi-turn conversations with memory; optional context compaction summarizes old turns once the transcript grows past acontextLimit, so long-running conversations stay within the window.- Plan → Act → Verify — optional planning (decompose the goal into steps first) and a critic (review the draft answer and revise once if it falls short) — a thinking agent.
- Tool-calling loop with production safety rails:
- No repeated tool calls — identical calls (even with reordered JSON keys) are collapsed.
- No-progress cap — two rounds that add nothing new force a final answer.
- Round cap — a hard ceiling on tool-calling rounds.
- Human-in-the-loop approval — mutating tools are gated through an
approvalhook; deny with a reason and the model adapts. Read-only tools never prompt. - Parallel tool execution — independent tool calls in one round run concurrently.
- Observation hook — collect side effects (citations, traces) as the loop runs.
- Resilience — wrap any client in
RetryingClientfor retry + backoff on transient failures. - Cost control & cancellation — cumulative token
usageon everyRunResult, an optionaltokenBudgetthat stops the loop early (finish.budget), and cooperativeTaskcancellation (returns best-so-far, finish.cancelled). - Output guardrails — validate the final answer with a host predicate; on failure it auto-regenerates with the reason fed back (bounded by
maxGuardrailRetries). Enforce JSON-parses, required fields, length, no-PII, etc. - Streaming —
agent.stream(query)yieldsAgentEvents (.status/.toolResult/.answerDelta/.reasoningDelta/.finished) so the answer arrives token-by-token;DeepSeekClientstreams via SSE, and any non-streaming client falls back to a whole-answer yield. - In-run context compaction — a long tool run summarizes the middle of its own transcript
(goal and recent tail kept verbatim, tool results never orphaned) and keeps working instead of
overflowing the context window. Set
compactionThresholdTokens; works in streaming runs too. - Structured output —
runStructured(MyType.self, …)returns the final answer decoded as a SwiftDecodable, with malformed JSON regenerated automatically (the decode error is fed back). Turns an agent into a typed pipeline stage. - Thinking mode (deepseek-v4) — opt in with
LLMConfig.thinking+reasoningEffort; the SDK handles the API contract (tool-call turns return theirreasoning_contentverbatim, streaming surfaces reasoning deltas for a "thinking…" UI). - Checkpoint / resume —
onCheckpointemits aCodableRunCheckpointafter every tool round; persist it (FileCheckpointStore) and a killed or crashed run continues from its last round viaresume(_:tools:)— transcript, de-dup memory, and counters intact. - Coding agent, sandboxed —
FileSandbox.codingTools()gives the loop read/write/edit/list/ glob/grep/run-command over one directory; commands can run under a macOS sandbox profile (no network, writes confined to the workdir). Point it at a folder and the agent writes & runs code to build deliverables. - Sub-agents — wrap any
Agentas aSubAgentTool, or expose a menu of named specialists throughSubAgentRouterTool(the agentic fan-out primitive) so a parent agent delegates focused sub-tasks (hierarchical / multi-agent workflows). - Built-in general tools —
CalculatorTool,UnitConvertTool,CurrentDateTimeTool,TranslateTool, plusWebSearchTool/WebFetchTool(host supplies aWebSearchEngine), ready to drop into any agent; bring your own for app-specific capabilities.
Fully mockable (inject any LLMClient) — the whole thing is testable offline, no network.
// Package.swift
dependencies: [
.package(url: "https://github.com/<you>/Fathom.git", from: "1.0.0")
],
targets: [
.target(name: "MyApp", dependencies: ["Fathom"])
]import Fathom
let client = DeepSeekClient(config: LLMConfig(apiKey: "sk-…"))
let search = ClosureTool(
name: "search",
description: "Search the knowledge base",
parameters: ["type": "object", "properties": [
"query": ["type": "string", "description": "what to look for"]
]]
) { argumentsJSON in
// decode argumentsJSON, do the work, return a textual result
"…results…"
}
let agent = Agent(
client: client,
systemPrompt: "You are a helpful research assistant.",
tools: [search]
)
let result = try await agent.run("What changed in the Q3 report?")
print(result.answer) // the model's final answer
print(result.toolCallCount) // how many tools ran
print(result.finish) // .natural / .noProgress / .roundLimitlet thread = agent.thread()
_ = try await thread.send("Summarize the report.")
_ = try await thread.send("Now compare it to last year.") // remembers the first turn
print(thread.messages) // the running transcriptMutating tools (isMutating: true) are gated through approval; read-only tools never prompt.
let agent = Agent(
client: client, systemPrompt: "…", tools: [deleteTool],
approval: { call in
await userConfirms(call) ? .allow : .deny("user declined")
}
)Turn on planning (decompose first) and the critic (review + revise) for a thinking agent:
let agent = Agent(client: client, systemPrompt: "…", tools: tools,
planning: true, critic: true)
let result = try await agent.run("Compare last two quarterly reports and flag risks.")
print(result.plan) // the steps it decomposed the goal into
print(result.revised) // true if the critic forced a revisionstruct Report: Decodable { let title: String; let risks: [String] }
let (report, run) = try await agent.runStructured(Report.self,
query: "Summarize Q3 and list the top risks.",
schemaHint: #"{"title": string, "risks": [string]}"#)
print(report.risks) // a real Swift value — no prose parsing
print(run.guardrailRetries) // how many malformed answers were regeneratedlet sandbox = FileSandbox(root: workdirURL)
let builder = Orchestrator(client: client, maxRounds: 16, planning: true,
compactionThresholdTokens: 100_000) // survives past the window
let result = try await builder.run(
systemPrompt: "You write and run code to produce the deliverable.",
query: "Build a PDF report from data.csv",
tools: sandbox.codingTools(commandTimeout: 180, sandboxed: true)) // no network, writes confinedlet store = FileCheckpointStore(url: checkpointURL)
var orch = Orchestrator(client: client, onCheckpoint: { store.save($0) })
let result = if let saved = store.load() {
try await orch.resume(saved, tools: tools) // pick up at the last completed round
} else {
try await orch.run(systemPrompt: sys, query: q, tools: tools)
}
store.clear()let cfg = LLMConfig(apiKey: "sk-…", model: "deepseek-v4-pro",
thinking: true, reasoningEffort: "high")
// The SDK returns each tool turn's reasoning_content to the API as required,
// and streams `.reasoningDelta` events for a "thinking…" UI.let client = RetryingClient(wrapping: DeepSeekClient(config: cfg), maxAttempts: 4)onObservation fires after every tool call (fresh or de-duplicated repeat) — the seam a
host uses to gather citations or traces without owning the loop:
let agent = Agent(
client: client, systemPrompt: "…", tools: tools,
onObservation: { obs in
print(obs.toolName, obs.arguments, obs.isRepeat, obs.approved)
}
)| Type | Purpose |
|---|---|
LLMClient |
Protocol for the chat endpoint — inject DeepSeekClient or a mock. |
DeepSeekClient |
URLSession transport for DeepSeek's chat/completions (public wire/parseCompletion; SSE streaming; thinking mode). |
OrchestratorTool / ClosureTool |
A capability the model can call. |
Orchestrator |
The tool-calling ACT loop with safety rails, compaction, checkpointing, and hooks. |
FileSandbox |
Directory-confined coding tools (read/write/edit/glob/grep/run, optional OS sandbox). |
RunCheckpoint / FileCheckpointStore |
Resumable run snapshots and their simplest persistence. |
SubAgentTool / SubAgentRouterTool |
Delegate sub-tasks to one specialist, or route across a menu of them. |
ChatMessage / ToolCall / Completion / RunResult / FinishReason |
Chat primitives (Codable where persistence needs them). |
swift test
The loop is exercised with a scripted mock LLMClient — no network or API key required.
Apache-2.0. See LICENSE.