soma.cell

Turn-bounded conversation agent — proves a chat loop terminates

Demonstrates: a fixed max-turns loop (no unbounded `chatting -> chatting` cycle that would break liveness), persistent transcript, and clear_context() between sessions. The bound is the verification-friendly idiom for "loops". Run: SOMA_LLM_MOCK=echo soma run turn_bounded_chat.cell --signal converse "hi" 2 Test: SOMA_LLM_MOCK=echo soma test turn_bounded_chat.cell

A Soma program from the agents part of the verified corpus: 61 lines, cells Chatbot, ChatTests. It passes soma check with soma 3.2.4.

How it is built

The cell Chatbot declares 1 signal in its face: converse(opening: String, turns: Int) -> Map; keeps 1 memory slot: transcript (List<String>, persistent). Its state machine session starts in idle and allows 3 transitions: idle -> talking, talking -> closed and * -> aborted. aborted and closed are terminal. Handlers: converse (line 25).

The file also holds 1 test cell (ChatTests) with 5 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"]

turn_bounded_chat.cell

// Turn-bounded conversation agent — proves a chat loop terminates.
// Demonstrates: a fixed max-turns loop (no unbounded `chatting -> chatting`
// cycle that would break liveness), persistent transcript, and clear_context()
// between sessions. The bound is the verification-friendly idiom for "loops".
//
// Run:  SOMA_LLM_MOCK=echo soma run turn_bounded_chat.cell --signal converse "hi" 2
// Test: SOMA_LLM_MOCK=echo soma test turn_bounded_chat.cell

cell agent Chatbot {
    face {
        signal converse(opening: String, turns: Int) -> Map
    }

    memory {
        transcript: List<String> [persistent]
    }

    state session {
        initial: idle
        idle    -> talking
        talking -> closed
        *       -> aborted
    }

    on converse(opening: String, turns: Int) {
        require turns > 0 else need_at_least_one_turn
        require turns <= 10 else too_many_turns
        set_budget(6000)
        let id = to_string(next_id())
        transition(id, "talking")
        clear_context()

        let said = 0
        let last = opening
        let n = 0
        while n < turns {
            n = n + 1
            let r = try { think("Reply briefly to: {last}") }
            if r.error != () {
                transition(id, "aborted")
                return map("status", "aborted", "error", r.error, "turns", said)
            }
            last = r.value
            said = said + 1
        }

        transition(id, "closed")
        map("status", "closed", "turns", said, "last", last)
    }
}

cell test ChatTests {
    rules {
        assert converse("hello", 1).turns == 1
        assert converse("hello", 3).turns == 3
        assert converse("hello", 3).status == "closed"
        assert_fails converse("hello", 0)
        assert_fails converse("hello", 50)
    }
}

What the toolchain says

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

$ soma check turn_bounded_chat.cell
✓ All checks passed.
$ soma test turn_bounded_chat.cell
test ChatTests ...
  ✓ assert converse("hello", 1).turns == 1
  ✓ assert converse("hello", 3).turns == 3
  ✓ assert converse("hello", 3).status == "closed"
  ✓ assert_fails converse("hello", 0) — raised require failed: need_at_least_one_turn: constraint violated
  ✓ assert_fails converse("hello", 50) — raised require failed: too_many_turns: constraint violated

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

State machine 'session': 4 states, initial 'idle'
  States: [aborted, closed, idle, talking]

  ✓ 4 states, 5 transitions
  ✓ all states reachable from 'idle'
  ✓ terminal states: [aborted]
  ✓ no deadlocks
  ✓ liveness: every state can eventually reach a terminal state
  ✓ wildcard transitions: * -> [aborted]
  ⚠ `* -> aborted` also adds closed -> aborted — so closed is not final. If it should stay final, write `* -> aborted except [closed]`
  ✓ think-isolated: CTL safety properties hold regardless of LLM output (1 handlers, 3 literal transitions, 0 dynamic)
  ⚠ handler `converse`: while-loop without provable termination bound (write `while [loop_bound(N)] cond { … }`, or a for-loop over a bounded range)
  ✓ refinement: handler `converse` ⟶ {talking, aborted [while n < turns ∧ if r.error != ()], closed}
  · transition sources: `converse` 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)
  ✓ effects: handler `converse` calls think() at 2 sites (the cost line counts loop rounds) — no tools available

9 passed, 2 warnings, 0 failures

  Temporal properties for 'session':
  ✓ 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

clear_context, map, next_id, set_budget, think, to_string, transition

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