Sorcerer 如何以原生 SwiftUI 建構多智能體模擬器
How I built a multi-agent simulator as a native SwiftUI app
Guilherme Brasil 開發了免費開源的 macOS 15+ 應用程式 Sorcerer,讓多個 AI 角色逐輪模擬對假設情境的反應。它可使用用戶已登入的 claude CLI(Claude Code)、codex CLI(ChatGPT),或本機 Ollama,毋須提供 API key。
I built Sorcerer, a free, open-source macOS app (SwiftUI, macOS 15+, MIT) that simulates a community of AI characters reacting to a "what if…?" question. You write a hypothesis ("what if Lumen raised its plan from $9 to $14 a month?"), summon a cast, and watch them post, reply, react, change their minds and take actions, round by round.
Repo: https://github.com/gbrasil720/sorcerer · Site: https://sorcererapp.dev
This post covers how it works under the hood.
1. No API keys: drive the CLIs people already have
I didn't want to ship a model or ask anyone for a key. Sorcerer runs the official tools the user is already signed in to: the claude CLI (Claude Code), the codex CLI (ChatGPT), or a local Ollama.
The first problem: an app launched from Finder doesn't get your Terminal's PATH, so claude is "command not found". Sorcerer asks an interactive zsh once and remembers the answer:
/// The PATH your Terminal has. A login shell alone misses what ~/.zshrc adds (~/.local/bin, where
/// the claude installer puts it; nvm, bun…), so ask an interactive shell once and remember it.
static let userPath: String = {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/bin/zsh")
p.arguments = ["-ilc", #"printf '\n__SORCERER_PATH__%s' "$PATH""#]
It also has a 5-second deadline, because an interactive shell can hang on a prompt or plugin.
Then each provider is one switch case. Claude runs with no tools, no session file and no user settings. It uses stream-json because that output also carries the subscription's 5-hour and weekly usage windows, so the app can pause before you hit a limit:
"claude -p --tools '' --no-session-persistence --setting-sources '' --output-format stream-json --verbose\(pick)"
+ (onText == nil ? "" : " --include-partial-messages"),
Codex runs with codex exec --skip-git-repo-check --ephemeral -s read-only … -o <tmpfile> -. Ollama is a plain POST to http://localhost:11434/api/generate with "stream": true, which reads one JSON object per line.
2. Characters
A character is a plain Codable struct:
struct Persona: Codable, Identifiable, Hashable {
var handle: String
var role: String
var bio = ""
var traits: [String: Double] = [:]
var interests: [String] = []
var goals: [String] = []
var mood = 0.0
var memories: [String] = []
/// handle → relation ("coworker", "rivals"). Optional so older worlds still load.
var ties: [String: String]?
/// Knows the brief's insider facts (employees, partners, friends of the team).
var insider: Bool?
mood is the character's attitude toward the hypothesis, from -1 to 1. ties are how influence spreads. memories are one-liners the model writes each round.
3. A round is one prompt, and the rules live in pure functions
The simulation lives in Engine.swift, whose header says "Prompts and parsing. Pure functions, so the simulation rules are testable without a model."
Each round, roundPrompt gives the model the whole world:
- the cast, with role, mood, ties, last 4 memories and last action
- past events and anything injected this round
- the last 24 posts, each with a stable id like
A3(third post of branch A) - what characters may know (more on that below)
The model plays every character and must reply with only JSON:
{"posts":[{"author":"handle","replyTo":"post id it answers, e.g. A3, or null","text":"...","mood":0.0}],"reactions":{"A3":{"agree":2,"disagree":1}},"memories":{"handle":"one short line this character will remember"}}
The prompt nudges toward realism: not everyone posts every round, opinions shift gradually, replies come most often from people with ties, and quiet characters still react with agree/disagree counts.
Then apply validates the output instead of trusting it. Unknown authors are dropped and moods are clamped. Actions must be on the experiment's allowed list. Only someone who left can "come back", and there's at most one invite per round. Because this is pure code, swift test covers it without calling a model.
4. Who knows what
You can point Sorcerer at a GitHub repo (through your own gh login), a folder, a site or files. It writes a brief where every fact is tagged public, insider or hidden. knowledge() builds each prompt's knowledge section: insider facts reach only characters marked insider, hidden ones reach nobody, and it ends with "Characters never quote facts they couldn't know." When reading sources, it only reads docs. .env files and keys are never opened, and characters see the brief, never your code.
5. Streaming half a JSON
To show "@wren is typing…" with words appearing live, Sorcerer parses the round's JSON while it streams:
/// Reads a round's JSON while it streams in. Complete post objects are decoded; the open one
/// yields its author and the text so far. Never throws: half a JSON is the normal case here.
static func partialRound(_ text: String) -> Partial {
It's a small hand-rolled scanner. It finds "posts", tracks brace depth and string escapes, decodes every closed {…} as a finished post, and pulls author and partial text out of the open one. Claude (with --include-partial-messages) and Ollama stream. Codex doesn't yet, and that's issue #21.
6. Forking the world
Forking copies the branch with the same people and the same memories, then starts a new path:
func fork() {
guard let b = shown, let e = experiment else { return }
let letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".map(String.init)
var copy = b
copy.id = UUID()
copy.name = letters[min(e.branches.count, letters.count - 1)]
copy.parent = "from \(b.name) at r\(b.round)"
mutate { $0.branches.append(copy) }
switchBranch(copy.id)
}
To fork from a past round, rewind(_:to:start:) returns the branch as it stood after round n. It filters posts, events, actions and polls, drops people who joined later, removes memories tagged with later rounds, and rebuilds each mood from the posts. There's an honest comment in there: agree/disagree counts are totals, so reactions from later rounds stay on old posts. That's a known trade-off.
7. Counted, not guessed
Reports separate what the model judges from what the world counted. If six people answer a poll, the report says "would you pay $14 · no · 3 / 6" from the actual answers. An action tally only counts each person's latest action, so "upgrade" then "cancel" counts once, as cancel.
Stack
- App: pure Swift, SwiftUI, Swift Package, no third-party dependencies. Run it with
cd apps/macos && swift runand test withswift test. - Landing page: Bun + Turbo monorepo (React, TanStack Router, Tailwind, Hono, tRPC), deployed on Cloudflare Pages.
- Releases: push a
vX.Y.Ztag and CI tests, builds a universal DMG and publishes it.
Want to help?
I opened well-scoped issues with context, file pointers and acceptance criteria:
- #15 Non-default Ollama host
- #16 Accessibility labels for VoiceOver
- #17 Add a new experiment template
- #18 Unit tests for Ollama stream parsing
- #19 Biome and landing build in CI
- #20 Remove leftover scaffold module
- #21 Stream codex replies (help wanted)
Tip: test with Ollama so you don't burn your Claude or ChatGPT limits.
Simulations surface possible reactions; they don't replace real-world evidence. I'd love to hear where it feels convincing and where it falls apart. Repo: https://github.com/gbrasil720/sorcerer
來源:DEV Community · dev.to