A session-typed static compiler for safe interactions in multi-agent systems.
Multi-agent systems fail in the spaces between agents: one agent acts before authorization, two agents wait forever (deadlock), everyone wastes tokens negotiating coordination. STJP type-checks the conversation itself before any agent runs β catching deadlocks, catching unsafe orderings, and compiling safe per-agent prompts with a runtime guard and scheduler.
- π Documentation
- π Quick Start
- π§ͺ Running Experiments
- π Understanding the Results
- π Creating a New Use Case
- π Troubleshooting
- π Project Structure
- π― Key Results (2026-07-02, n=10 finance case, GPT-5.4)
- π Next Steps
- π€ Contributing
- β Questions?
- π License
- π Citation
Start here: Read docs/README.md for the full documentation index.
Quick tours:
- What is STJP? β
docs/1_TECH_SETUP.md(15 min) - How do we test it? β
docs/2_TESTING_STRATEGIES.md(20 min) - What is each "arm"? β
docs/5_ARMS_EXPLAINED.md(10 min) - Latest results β
docs/6_RUN_REPORTS_EXPLAINED.md(plain English) - Why safety matters β
docs/7_USE_CASE_DEADLOCK_SAFETY.md(real examples) - Is the benchmark fair? β
docs/BENCHMARK_FAIRNESS_REVIEW.md(the audit)
- Python 3.13
- Java 17 (for Scribble compiler)
- Azure subscription with Azure AI Foundry access
- Git
# Clone the repo
git clone https://github.com/ginaecho/session-typed-agents
cd session-typed-agents
# Clone Scribble (the static checker)
git clone https://github.com/scribble/scribble-java
# Install Python dependencies
pip install -r stjp_core/requirements-core.txt
# Configure Azure
az login
# Create .env file (see "Configuration" section below)Create stjp_core/.env:
AZURE_TENANT_ID=<your-tenant-id>
AZURE_SUBSCRIPTION_ID=<your-subscription-id>
AZURE_RESOURCE_GROUP=<your-resource-group>
AZURE_PROJECT_NAME=<your-ai-foundry-project>
AZURE_LOCATION=eastus
OPENAI_API_KEY=<your-openai-key> # optional, for non-Foundry models
See stjp_core/CLAUDE.md for detailed setup instructions.
If you are running the existing benchmark and cases, you do not need to change anything β the defaults preserve the original behavior. Two capabilities were added as optional extensions, layered beside the old behavior rather than replacing it:
| capability | default | how to turn it on | effect when off |
|---|---|---|---|
| Protocol checker backend | the original Scribble-Java compiler (STJP_COMPILER_BACKEND=scribble) |
set env var STJP_COMPILER_BACKEND=nuscr (plus STJP_NUSCR_BIN) |
the newer nuscr checker is never invoked; validation is unchanged |
| Stateful session invariants (the "ledger": running value constraints across a whole conversation, e.g. a budget that must never go negative) | off | add a __ledger__ entry to a case's refinement (.refn) sidecar |
monitor.py sets self.ledger = None and behaves exactly as before; no case without a __ledger__ entry is affected |
What this means in practice: a checkout that does not set
STJP_COMPILER_BACKEND and whose cases declare no __ledger__ entry runs
against the original Scribble checker with the original monitor logic β
identical to before these extensions existed. Existing tests and cases are
unaffected. Turn either extension on deliberately, per run, only when you
want it.
The simplest way to test STJP:
cd experiments
# Run finance case, 1 trial, with the full STJP stack (min_llmvalid_sched)
python scripts/case_runner.py finance 1 --arms min_llmvalid_sched
# Check results
ls cases/finance/runs/Output:
events_min_llmvalid_sched.jsonlβ every message, in ordersummary.jsonβ metrics (success rate, tokens, violations)prompts/min_llmvalid_sched/β the exact prompts each agent saw
Run intent-only vs the full STJP stack on the same case:
# Run 3 trials each
python scripts/case_runner.py finance 3 \
--arms bare,min_llmvalid_schedOutput comparison:
| Metric | bare (intent-only) | min_llmvalid_sched (STJP) |
|---|---|---|
| Success rate | Often 0% | 100% |
| Disasters | Usually many | 0 |
| Tokens/trial | Highly variable | ~13k (optimized) |
| Cost-to-goal | β (fails) | 13k/success |
This runs all arms on a case with n=10 trials each (the 2026-07-02 official
run of this kind produced docs/results/RESULT_04_FULL_STACK.md):
# Run all arms (this is the official benchmark)
python scripts/case_runner.py finance 10
# View dashboard
python scripts/index_builder.py
open INDEX.htmlThe arms tested (15 in the current registry, experiments/baselines/registry.py;
each is one configuration being compared β like the treatment and control
groups of a medical trial):
- bare, maf_native, maf_foundry, maf_groupchat β intent only, on different runtimes (baselines)
- maf_groupchat_unsafe β a compiler-REJECTED protocol pasted as text
- maf_groupchat_llmvalid β validated protocol text with an LLM orchestrator
- unchecked_skills β human-written/downloaded skills, never compiler-checked
- global_decentralized β validated protocol text, decentralized
- spec_llmvalid / min_llmvalid β verbose / minimal per-agent contract (observer, no enforcement)
- spec_llmvalid_gate / min_llmvalid_gate β contract + enforcement gate
- min_llmvalid_gate_nohint β gate without the per-turn state hint (isolates how much the reminder itself contributes)
- min_llmvalid_gate_lastrecv β gate + "ask whoever just received a message" turn-taking (a protocol-free stand-in for the scheduler, so the scheduler's win can't be credited to mere orderliness)
- min_llmvalid_sched β lean contract + gate + EFSM scheduler (full STJP)
Each arm is drawn as a one-line flow diagram in
docs/5_ARMS_EXPLAINED.md.
Every case is a directory under experiments/cases/.
The current set:
| Case | One-line description |
|---|---|
agenticpay_settlement |
Payment settlement demo run on Azure AI Foundry (deadlock vs checked protocol) |
auction |
Sealed-bid multi-bidder auction with winner/outbid logic |
banking |
Transfer with amount-based approval/rejection branches |
clinical_enrollment |
Trial enrollment with screening, consent, lab, ethics approvals |
code_review |
PR review with reviewer quorum and CI gating |
composition |
Composed sub-protocol examples (audit, banking, pipeline) |
finance |
Quarterly revenue report with conditional audit (the flagship benchmark case) |
finance_nested |
Nested 2Γ2 branching with payload-driven choices |
intel_report |
Multi-source intel fan-in, then review/publish pipeline |
iterative_polling |
Looping poll-and-log workflow |
nested_retry |
Loop + nested branching editorial workflow |
planner_workers |
Planner fanning work out to worker agents |
rag |
Multi-source retrieval + verification loop |
report_pipeline |
6-role linear pipeline (token-efficiency demo) |
report_pipeline_large |
10-role scaled linear pipeline |
retry_loop |
Worker/manager retry-until-accept loop |
trade_deadlock |
Intentional circular-wait deadlock demo |
trade_settlement |
Goods-for-payment with hidden circular dependency |
travel |
All-or-nothing travel booking with rollback |
travel_saga |
3-supplier booking happy path |
skills_safety/airline_seat |
Airline customer-service team from a real OpenAI Agents SDK example (wrong-order safety) |
skills_safety/booking_saga |
LangGraph supervisor booking saga (deadlock / saga ordering) |
skills_safety/code_execution |
AutoGen write-then-execute code pair (execute-before-review safety) |
skills_safety/content_pipeline |
CrewAI research/write/edit/publish content team (wrong-order safety) |
skills_safety/doc_pipeline |
Announcement team from real Anthropic public skills |
skills_safety/doc_coauthor_ship |
Corrected announcement case (looping protocol) |
skills_safety/pr_merge |
Code-change team from real GitHub Copilot public files |
skills_safety/pr_review_merge |
Corrected code-review case (looping protocol) |
_corpus |
Generated protocol corpus used by the stress/mutation benchmarks |
# Try the deadlock case (tests claim: "only static check catches deadlock")
python scripts/case_runner.py trade_deadlock 3 --arms bare,min_llmvalid_sched
# This should show:
# - bare: 0% success (agents deadlock forever)
# - min_llmvalid_sched: 100% success (Scribble caught it before running)# Run a subset of arms
python scripts/case_runner.py finance 5 \
--arms bare,min_llmvalid_gate,min_llmvalid_sched
# Run with semantic goal evaluation (LLM judge for goals)
python scripts/case_runner.py finance 3 --semantic
# Run and log to file
python scripts/case_runner.py finance 5 2>&1 | tee my_run.log
# Run arms one at a time instead of in parallel (--sequential), so wall-clock
# timings are trustworthy: parallel arms compete for the same API quota, so a
# "slow" arm may just have been starved by its neighbors
python scripts/case_runner.py finance 5 --sequential
# See all options
python scripts/case_runner.py --help{
"case": "finance",
"arm": "min_llmvalid_sched",
"n_trials": 10,
"succeeded": 10,
"success_rate_pct": 100,
"viol_events": 0,
"total_tokens": 133140,
"total_seconds": 320,
"tokens_per_trial": 13314,
"calls_per_trial": 11.4,
"cost_to_goal": 13314
}Key metrics:
- success_rate_pct β % of trials that finished correctly (0β100%)
- viol_events β how many messages violated the protocol
- cost_to_goal β tokens Γ· success rate (the true cost of delivery)
{
"strict_pct": 100,
"role_pair_pct": 100,
"strict_per_goal": {
"G1_high_revenue_above_50k": 100,
"G2_audit_if_high": 100
}
}Key metrics:
- strict_pct β % of trials where all goals achieved (100% = perfect)
- role_pair_pct β relaxed version (any message between roles counts)
# View the message trace
tail -50 cases/finance/runs/LATEST/events_min_llmvalid_sched.jsonl
# Human-readable format
cat cases/finance/runs/LATEST/events_min_llmvalid_sched.jsonl | jq '.[] | "\(.step): \(.sender) -> \(.receiver) [\(.label)] = \(.payload)"'Each line is a message:
{
"step": 3,
"sender": "Fetcher",
"receiver": "TaxSpecialist",
"label": "HighRevenue",
"payload": "60000",
"violation": null
}If violation is not null, the protocol was violated:
{
"violation": {
"type": "off_protocol",
"role": "TaxSpecialist",
"state": "state_5",
"expected": ["HighRevenue"]
}
}Follow the step-by-step guide in docs/4_HOW_TO_CREATE_USE_CASES.md.
Quick checklist:
-
Create case directory:
mkdir -p experiments/cases/my_case/protocols
-
Write
case.yaml:case_id: my_case description: My new protocol version: v1 protocol_name: MyProtocol roles: [Agent1, Agent2, Agent3] terminal_label: Done intent: | Your task description here goals: - id: G1 description: First goal anchor: {sender: Agent1, receiver: Agent2, label: FirstMessage}
-
Write protocol (
protocols/v1.scr):global protocol MyProtocol(role Agent1, role Agent2, role Agent3) { msg1(String) from Agent1 to Agent2; msg2(String) from Agent2 to Agent3; done(String) from Agent3 to Agent1; } -
Add refinements (
protocols/v1.refn, optional):[Agent1 -> Agent2 : msg1] type: str require: len(x) > 0 -
Test it:
python scripts/case_runner.py my_case 3 --arms bare,min_llmvalid_sched
See docs/4_HOW_TO_CREATE_USE_CASES.md for detailed steps.
The Scribble compiler found a problem (deadlock, unreachable state, inconsistency).
Fix:
- Read the Scribble error message carefully
- Check
docs/7_USE_CASE_DEADLOCK_SAFETY.mdfor examples - Revise your protocol and re-run
Agents reach a state where nothing can happen next (liveness failure).
Causes:
- Missing a required message in the protocol
- Refinement too strict (payloads fail validation)
- Wrong choice logic
Debug:
# View the trace where it got stuck
cat cases/<case>/runs/LATEST/events_<arm>.jsonl | jq '.[] | select(.step > 20)'
# Check the agent's view at that step
tail -100 <run_dir>/<agent>_transcript.logIf intent-only (bare) uses way more tokens than expected:
- Expected: Agents guess, debate coordination, waste tokens (that's the point of the test)
- If bare is cheap: Try a harder case that requires real coordination
# Verify login
az account show
# Re-authenticate
az login --use-device-code.
βββ docs/ # Documentation (start here)
β βββ README.md # Docs index
β βββ 1_TECH_SETUP.md # Foundation guide
β βββ 2_TESTING_STRATEGIES.md # Testing methodology
β βββ 3_BENCHMARK_DESIGN_EXPLAINED.md
β βββ 4_HOW_TO_CREATE_USE_CASES.md
β βββ 5_ARMS_EXPLAINED.md # Every arm as one flow line
β βββ 6_RUN_REPORTS_EXPLAINED.md
β βββ 7_USE_CASE_DEADLOCK_SAFETY.md
β βββ 8_INTENT_TO_PROTOCOL_TRAINING.md
β βββ BENCHMARK_FAIRNESS_REVIEW.md # Fairness audit of the benchmark
β βββ reference/ # Current technical deep-dives (glossary, Scribble
β β # extensions, gate internals, Foundry wiring, v3 plan)
β βββ results/ # Current evidence (RESULT_00 β¦ RESULT_11 + runs/)
β βββ predictions/ # Pre-registered predictions (written before runs)
β βββ diary/ # Project journal
β βββ archive/ # Superseded docs (nothing deleted; see its README.md)
βββ experiments/
β βββ cases/ # All benchmark cases (see the table above)
β β βββ finance/ # Example case
β β βββ banking/
β β βββ trade_deadlock/
β β βββ ... # 20+ more, incl. skills_safety/
β βββ baselines/ # The 15 arms (runners)
β βββ scripts/
β β βββ case_runner.py # Main benchmark driver
β β βββ case_loader.py # Load case.yaml
β β βββ index_builder.py # Build dashboard
β βββ INDEX.html # Results dashboard
βββ stjp_core/ # Library
β βββ compiler/ # Scribble integration
β βββ monitor/ # Runtime monitor
β βββ foundry/ # Azure integration
β βββ generation/ # Skill/prompt generation
β βββ CLAUDE.md # Setup guide
βββ scribble-java/ # Scribble compiler (vendored)
βββ ROADMAP.md # Future phases
βββ README.md # (you are here)
| Arm | Success | Disasters | Cost-to-goal | Calls | Speed |
|---|---|---|---|---|---|
| bare (intent-only) | 0% | 18 | β | β | β |
| global text | 100% | 0 | 120k | 41.8 | 124s |
| local contract | 60% | 0 | 144k | 84.9 | 223s |
| STJP (full stack) | 100% | 0 | 13.3k | 11.4 | 32s |
The headline: STJP is 9Γ cheaper, 4Γ faster, same safety as global protocol text.
Read this table with two caveats (from the
fairness review): bare's 0% was scored
under the strict label-matching rule, which grades the bare team on message
labels it was never shown β so treat 0% as a lower bound until the fair-rule
re-run lands. And the seconds column came from arms running in parallel
against one rate-limited deployment, so speed claims should rest on the
tokens and calls columns; re-measure time with
case_runner.py --sequential.
See docs/6_RUN_REPORTS_EXPLAINED.md for the full breakdown.
Learn the system:
- Read
docs/1_TECH_SETUP.md(15 min) - Run a 1-trial test:
python experiments/scripts/case_runner.py finance 1 --arms min_llmvalid_sched - Read
docs/6_RUN_REPORTS_EXPLAINED.mdto interpret results
Run the full benchmark:
- Read
docs/2_TESTING_STRATEGIES.md(understand fairness) - Run:
python experiments/scripts/case_runner.py finance 10 - View:
python experiments/scripts/index_builder.py && open INDEX.html
Create your own case:
- Follow
docs/4_HOW_TO_CREATE_USE_CASES.md - Add a new directory under
experiments/cases/ - Define protocol, case config, goals
- Test with:
python experiments/scripts/case_runner.py <case_id> 3
New cases: Add to experiments/cases/. Start with a task description, protocol (Scribble), and goals.
New arms: Add to experiments/baselines/registry.py and instructions.py. See experiments/CLAUDE.md for the mechanics.
Theory/roadmap: See ROADMAP.md and RESEARCH.md.
- How do I run STJP? β This README (you are here)
- What is STJP technically? β
docs/1_TECH_SETUP.md - Why are tests confounded? β
docs/2_TESTING_STRATEGIES.md - How do I read results? β
docs/6_RUN_REPORTS_EXPLAINED.md - Why does safety matter? β
docs/7_USE_CASE_DEADLOCK_SAFETY.md - How do I create a case? β
docs/4_HOW_TO_CREATE_USE_CASES.md
MIT Β© Tzu-Chun Chen and contributors.
Scribble (vendored in scribble-java/) is Apache 2.0 Β© Imperial College.
@software{chen2026stjp,
author = {Chen, Tzu-Chun},
title = {Session-Typed Skills: A static compiler for safe multi-agent interactions},
year = {2026},
url = {https://github.com/ginaecho/session-typed-agents}
}