soma.cell

Sentiment-routing agent: classify a message, route by polarity

Demonstrates: a match-based deterministic classifier as a tool, a router that branches the lifecycle on the result, and group_by-style bucketing over a batch. think() is available for the fuzzy path but tools are tested. Run: SOMA_LLM_MOCK=echo soma run sentiment_router.cell --signal route "I love this" Test: SOMA_LLM_MOCK=echo soma test sentiment_router.cell

A Soma program from the agents part of the verified corpus: 67 lines, cells Router, RouterTests. It passes soma check with soma 3.2.4.

How it is built

The cell Router declares 2 signals in its face: route(msg: String) -> Map and batch_summary(msgs: List<String>) -> Map. Its state machine task starts in idle and allows 3 transitions: idle -> classifying, classifying -> routed and * -> failed. failed and routed are terminal. Handlers: polarity (line 23), route (line 30) and batch_summary (line 44).

The file also holds 1 test cell (RouterTests) with 7 assertions, run by soma test.

Generated from soma describe on this file.

The source

soma.toml

[package]
name = "agents-corpus"
version = "0.1.0"
description = "Verified AI-agent example cells (training corpus)"

# Agent LLM config. `mock = "echo"` keeps every example in this directory
# hermetic: `soma test` / `soma run` need no network and no API key, and
# results are deterministic. For a live run, delete the `mock` line (or
# override with SOMA_LLM_MOCK) and provide the key via env.
[agent]
provider = "anthropic"
model    = "claude-opus-4-6"
key      = "${ANTHROPIC_API_KEY}"
mock     = "echo"

# Termination proof for verified_termination.cell's `workflow` machine:
# every path eventually reaches a terminal, no deadlocks.
# `cells` scopes these properties to Solver — the other agents in this
# directory have their own state names and must not inherit them.
[verify]
cells = ["Solver"]
deadlock_free = true
eventually    = ["done", "failed"]

[verify.after.understanding]
eventually = ["solving", "failed"]

[verify.after.solving]
eventually = ["verifying", "failed"]

[verify.after.verifying]
eventually = ["done", "failed"]

sentiment_router.cell

// Sentiment-routing agent: classify a message, route by polarity.
// Demonstrates: a match-based deterministic classifier as a tool, a router
// that branches the lifecycle on the result, and group_by-style bucketing
// over a batch. think() is available for the fuzzy path but tools are tested.
//
// Run:  SOMA_LLM_MOCK=echo soma run sentiment_router.cell --signal route "I love this"
// Test: SOMA_LLM_MOCK=echo soma test sentiment_router.cell

cell agent Router {
    face {
        signal route(msg: String) -> Map
        tool polarity(msg: String) -> String "positive, negative, or neutral"
        signal batch_summary(msgs: List<String>) -> Map
    }

    state task {
        initial: idle
        idle    -> classifying
        classifying -> routed
        *       -> failed
    }

    on polarity(msg: String) {
        let m = lowercase(msg)
        if contains(m, "love") || contains(m, "great") || contains(m, "good") { return "positive" }
        if contains(m, "hate") || contains(m, "bad") || contains(m, "broken") { return "negative" }
        "neutral"
    }

    on route(msg: String) {
        set_budget(1000)
        let id = to_string(next_id())
        transition(id, "classifying")
        let p = polarity(msg)
        let team = match p {
            "positive" -> "marketing"
            "negative" -> "support"
            _          -> "triage"
        }
        transition(id, "routed")
        map("status", "routed", "polarity", p, "team", team)
    }

    on batch_summary(msgs: List<String>) {
        let pos = 0
        let neg = 0
        for m in msgs {
            let p = polarity(m)
            if p == "positive" { pos = pos + 1 }
            if p == "negative" { neg = neg + 1 }
        }
        map("total", len(msgs), "positive", pos, "negative", neg)
    }
}

cell test RouterTests {
    rules {
        assert polarity("I love this") == "positive"
        assert polarity("this is broken") == "negative"
        assert polarity("it is a thing") == "neutral"
        assert route("great work").team == "marketing"
        assert route("totally bad").team == "support"
        assert batch_summary(list("good", "bad", "meh", "love it")).positive == 2
        assert batch_summary(list("good", "bad", "meh", "love it")).negative == 1
    }
}

What the toolchain says

Run on a fresh copy of the program at build time; the outputs are pasted unedited.

$ soma check sentiment_router.cell
✓ All checks passed.
$ soma test sentiment_router.cell
test RouterTests ...
  ✓ assert polarity("I love this") == "positive"
  ✓ assert polarity("this is broken") == "negative"
  ✓ assert polarity("it is a thing") == "neutral"
  ✓ assert route("great work").team == "marketing"
  ✓ assert route("totally bad").team == "support"
  ✓ assert batch_summary(list("good", "bad", "meh", "love it")).positive == 2
  ✓ assert batch_summary(list("good", "bad", "meh", "love it")).negative == 1

7 tests: 7 passed, 0 failed
$ soma verify sentiment_router.cell
Verifying sentiment_router.cell...
note: soma.toml [verify] cells names 'Solver', which this file does not define — its properties are not checked here

State machine 'task': 4 states, initial 'idle'
  States: [classifying, failed, idle, routed]

  ✓ 4 states, 5 transitions
  ✓ all states reachable from 'idle'
  ✓ terminal states: [failed]
  ✓ no deadlocks
  ✓ liveness: every state can eventually reach a terminal state
  ✓ wildcard transitions: * -> [failed]
  ⚠ `* -> failed` also adds routed -> failed — so routed is not final. If it should stay final, write `* -> failed except [routed]`
  ✓ think-isolated: CTL safety properties hold regardless of LLM output (1 handlers, 2 literal transitions, 0 dynamic)
  ✓ termination: all 3 handlers structurally terminate
  ⚠ refinement: declared transition `* → failed` is never reached by any handler — spec may be aspirational or stale
  ✓ refinement: handler `route` ⟶ {classifying, routed}
  · transition sources: `route` calls transition(id, target) without declaring the source — the runtime refuses a move the graph forbids, but the proof does not see where the handler starts; `transition(id, from, to)` names the edge (checked by `soma check`, refused at run time from any other state)

9 passed, 2 warnings, 0 failures

  Temporal properties for 'task':
  ✓ deadlock_free — no deadlocks in any reachable state
soma.toml: 4 user-defined properties loaded — NONE applies to this file ([verify] cells = ["Solver"] names no cell of it)
Temporal: 1 passed, 0 failed
VERIFY OK

Builtins used

contains, len, list, lowercase, map, next_id, set_budget, to_string, transition

Source file: /corpus/agents/sentiment_router.cell · examples/corpus/agents/sentiment_router.cell