跳到正文
DEV Community· Guilherme Brasil·· 4 小時前AI 評分55

Sorcerer 如何以原生 SwiftUI 建構多智能體模擬器

How I built a multi-agent simulator as a native SwiftUI app

AI 導讀

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 run and test with swift test.
  • Landing page: Bun + Turbo monorepo (React, TanStack Router, Tailwind, Hono, tRPC), deployed on Cloudflare Pages.
  • Releases: push a vX.Y.Z tag 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:

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