Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
python-version: ${{ matrix.python-version }}

- name: Install the CLI
run: python -m pip install --disable-pip-version-check -e .
run: python -m pip install --disable-pip-version-check -e '.[test]'

- name: Smoke-test the installed command
run: madp --help
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ git clone https://github.com/getaskclaw/multi-agent-dialogue-protocol.git
cd multi-agent-dialogue-protocol
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -e .
python -m pip install -e '.[test]'
```

## Verify
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ MADP is useful when you need:
- a fixed turn order and hard final stop;
- one explicit agent launch at a time;
- provider/model/session evidence instead of Markdown identity labels;
- frozen-definition-preauthorized substitute actors for a round when a primary runtime is unavailable, without identity impersonation;
- one Git commit for initialization, every accepted turn, and the owner decision;
- fail-closed behavior when evidence, process cleanup, state, or Git provenance is wrong.

Expand Down Expand Up @@ -67,7 +68,7 @@ madp validate DIR --require-git --require-runner-completion
madp owner-decide DIR --decision DECISION.md
```

`madp run` is dry-run by default unless `--launch` is present. One launch processes at most one scheduled turn. Automatic loops are deliberately outside the engine.
`madp run` is dry-run by default unless `--launch` is present. One launch processes at most one scheduled turn. A turn may freeze an ordered `substitute_actor_ids` list and a bounded `substitution_reasons` allowlist beside its primary `actor_id`; `status` shows the legal routes, and the operator explicitly selects a substitute with `--actor ... --substitution-reason ...`. The completed turn records the primary actor, actual runtime actor, and reason. Automatic loops and automatic cooldown classification are deliberately outside the engine.

## Supported adapters

Expand Down
71 changes: 65 additions & 6 deletions docs/technical-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,20 @@ madp owner-decide DIR --decision DECISION.md
dialogue path that already contains generated files
(`state.json`, `definition.json`, `.gitignore`).
2. **`madp status DIR`** — one JSON response naming the current state,
the next scheduled actor and round, the active claim,
the next primary scheduled actor, every frozen-definition-preauthorized substitute
actor for that round, the active claim,
`blocked_reason` (why nothing can proceed, or `null`),
`next_legal_action` (the one thing that may legally happen next),
and `recovered_turns` (how many completed turns carry
caller-supplied provenance).
3. **`madp run DIR --actor ACTOR --launch`** — executes exactly one
scheduled turn through its transport adapter and completes it. This
scheduled turn through the selected actor's transport adapter and
completes it. `ACTOR` must be either the primary `actor_id` or one of
that turn's frozen `substitute_actor_ids`. A substitute also requires
`--substitution-reason REASON`, where `REASON` is frozen in that turn's
`substitution_reasons` allowlist. A substitute keeps its own
actor/provider/model/session identity and never impersonates the primary.
This
is the **only production-honest completion door**: it is the only
path that records `completed_via: runner-launch`. Run it once per
scheduled turn (dry-run is the default without `--launch`:
Expand Down Expand Up @@ -58,6 +65,53 @@ evidence file is flagged here AND by `validate` independently;
real acceptance path in a fresh local dialogue and validates it with
the production gate (always local-only).

## Primary actors and frozen-definition substitutes

Every schedule entry still names one primary `actor_id`. It may also name an
ordered `substitute_actor_ids` list. Both are frozen into `definition.json`
at initialization and therefore covered by the definition digest and init
commit. A substitute is allowed only when:

- its actor definition already exists with explicit transport, provider, and
model constraints;
- its protocol `role` exactly matches the primary actor's role, so failover
cannot turn a challenge into another proposal or otherwise change the seat;
- its ID appears in that exact turn's `substitute_actor_ids` list;
- the operator explicitly selects it with `run --actor ...
--substitution-reason ... --launch`, using a reason code frozen in the
same turn's `substitution_reasons`; and
- its adapter-derived runtime evidence matches its own constraints.

The claim, adapter-derived runtime evidence, completed-turn state, command
result, task briefing, and Git
provenance record all separate:

- `scheduled_actor_id` — the primary actor named by the schedule;
- `actor_id` — the actual runtime actor selected for this turn;
- `actor_selection` — `primary` or `substitute`; and
- `substitution_reason` — `null` for the primary, otherwise the selected
frozen reason code.

This proves preauthorization by the frozen definition, not authenticated owner
approval and not automatic failover. MADP does not authenticate who initialized
the definition; `owner_proof_argv` applies only to the final owner decision.
MADP also does not diagnose a
provider cooldown or choose a substitute on its own. An external scheduler or
human may select an already frozen substitute after verifying the primary is
unavailable. If the active definition did not preapprove a suitable
substitute, do not edit it: create a new bounded continuation with a higher
version and point its evidence roots at the prior instance.

A bounded continuation may also include a structured `continuation` anchor.
MADP then rechecks the imported artifact's SHA-256, exact publication commit,
original dialogue HEAD, and `start_round` before initialization and on every
state read. Mutable evidence-root paths alone are not continuation proof.

When `agent_final_statuses` is configured, the final worker artifact must
contain exactly one `Status: <VALUE>` line from that frozen enum. Those tokens
must not overlap `owner_decisions`; reaching that status moves the dialogue to
`READY_FOR_OWNER` but never creates an owner decision.

```bash
python3 -m unittest discover -s tests # full suite (PYTHONPATH=src, or run scripts/verify.py)
python3 scripts/verify.py # compile + tests + schemas + secret scan + git hygiene
Expand All @@ -72,8 +126,8 @@ top-level CLI. They live, together with `release`, in an explicitly
unverified recovery namespace:

```bash
python -m multi_agent_dialogue.unverified claim DIR --actor ACTOR [--revision N]
python -m multi_agent_dialogue.unverified prepare DIR --actor ACTOR --output TASK.md
python -m multi_agent_dialogue.unverified claim DIR --actor ACTOR [--substitution-reason REASON] [--revision N]
python -m multi_agent_dialogue.unverified prepare DIR --actor ACTOR [--substitution-reason REASON] --output TASK.md
python -m multi_agent_dialogue.unverified complete DIR --actor ACTOR --turn TURN.md --runtime-evidence EVIDENCE.json
python -m multi_agent_dialogue.unverified release DIR --actor ACTOR
```
Expand Down Expand Up @@ -364,6 +418,8 @@ happens to see:
- **Trailers identify the runtime and the completion door.** Commit
messages carry non-secret machine-readable trailers:
`Madp-Protocol`, `Madp-Event`, `Madp-Round`, `Madp-Actor`,
`Madp-Scheduled-Actor`, `Madp-Actor-Selection`,
`Madp-Substitution-Reason`,
`Madp-Transport`, `Madp-Provider`, `Madp-Model`, `Madp-Session`,
`Madp-Completed-Via`, `Madp-Artifact-Sha256`,
`Madp-Evidence-Sha256` (turn commits), `Madp-Definition-Digest`
Expand Down Expand Up @@ -408,7 +464,9 @@ the humans or schedulers around the protocol.

- claims are atomic (`O_EXCL` lock + compare-and-swap revision); two
writers can never own one turn;
- wrong actor, wrong round, duplicate claim, stale revision → error;
- an actor not frozen as either the primary or an approved substitute for
that exact round, a missing/unapproved substitution reason, wrong round,
duplicate claim, or stale revision → error;
- published turns are immutable; any byte change flips the dialogue to
`BLOCKED` before the next completion or decision;
- missing, malformed, or mismatched runtime evidence blocks completion;
Expand Down Expand Up @@ -461,7 +519,8 @@ are one local Git commit each.
- **Same model ≠ diverse models.** Two sessions of the same
provider/model are separate contexts and separate session IDs, but
they are *not* model-diverse evidence, and the engine does not claim
otherwise.
otherwise. This remains true when the second session is an explicitly
recorded substitute actor.
- **Model audit ≠ provider proof.** The fable audit is the authority
for model purity of the observed stream, and the Hermes `state.db`
records the billing provider it was configured with; neither
Expand Down
2 changes: 1 addition & 1 deletion examples/demo-common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ madp_example_assert_wrong_actor() {
local work_dir="$DIALOGUE/work"
local marker="$work_dir/wrong-actor-spawn-marker"
local output="$work_dir/wrong-actor-output.txt"
local expected_error="'$wrong_actor' is not the scheduled actor for $round_id; next actor is '$expected_actor'"
local expected_error="'$wrong_actor' is not an allowed actor for $round_id; primary actor is '$expected_actor'"
local state_before
local state_after

Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ classifiers = [
]
dependencies = []

[project.optional-dependencies]
test = ["jsonschema>=4.23,<5"]

[project.urls]
Homepage = "https://github.com/getaskclaw/multi-agent-dialogue-protocol"
Repository = "https://github.com/getaskclaw/multi-agent-dialogue-protocol"
Expand Down
67 changes: 66 additions & 1 deletion schemas/protocol.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@
"minItems": 1,
"items": {"type": "string", "minLength": 1}
},
"agent_final_statuses": {
"type": "array",
"uniqueItems": true,
"items": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$"},
"description": "Optional frozen statuses for the final agent turn. They must not overlap owner_decisions. When set, the final artifact must contain exactly one Status: <VALUE> line."
},
"owner_proof_argv": {
"type": "array",
"items": {"type": "string", "minLength": 1},
Expand All @@ -39,6 +45,21 @@
"items": {"type": "integer", "minimum": 1},
"description": "Closed set of evidence-record versions this dialogue accepts (default: the engine-supported set). A turn whose evidence_version is outside this set fails closed; versions the engine cannot interpret are rejected at definition load even if listed."
},
"continuation": {
"type": "object",
"additionalProperties": false,
"required": ["protocol_id", "round_id", "artifact_path", "artifact_sha256", "published_commit", "original_dialogue_head", "start_round"],
"properties": {
"protocol_id": {"type": "string", "minLength": 1},
"round_id": {"type": "string", "minLength": 1},
"artifact_path": {"type": "string", "pattern": "^/"},
"artifact_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
"published_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
"original_dialogue_head": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
"start_round": {"type": "string", "minLength": 1}
},
"description": "Optional machine-checked anchor for a bounded continuation. The artifact's live bytes, publication commit, original dialogue head, and first new round are frozen into the definition and rechecked on every state read."
},
"actors": {
"type": "array",
"minItems": 2,
Expand Down Expand Up @@ -81,9 +102,53 @@
"type": "object",
"required": ["round_id", "actor_id", "purpose", "artifact_kind"],
"additionalProperties": false,
"allOf": [
{
"if": {
"required": ["substitute_actor_ids"],
"properties": {"substitute_actor_ids": {"minItems": 1}}
},
"then": {
"required": ["substitution_reasons"],
"properties": {"substitution_reasons": {"minItems": 1}}
},
"else": {
"properties": {"substitution_reasons": {"maxItems": 0}}
}
},
{
"if": {
"required": ["substitution_reasons"],
"properties": {"substitution_reasons": {"minItems": 1}}
},
"then": {
"required": ["substitute_actor_ids"],
"properties": {"substitute_actor_ids": {"minItems": 1}}
},
"else": {
"properties": {"substitute_actor_ids": {"maxItems": 0}}
}
}
],
"properties": {
"round_id": {"type": "string", "minLength": 1},
"actor_id": {"type": "string", "minLength": 1},
"actor_id": {
"type": "string",
"minLength": 1,
"description": "Primary scheduled actor for this turn."
},
"substitute_actor_ids": {
"type": "array",
"uniqueItems": true,
"items": {"type": "string", "minLength": 1},
"description": "Ordered alternative actors preauthorized by the frozen definition. Each substitute must have the same protocol role as the primary actor_id, retains its own actor/provider/model identity, and never impersonates actor_id. Initialization does not authenticate who approved the definition."
},
"substitution_reasons": {
"type": "array",
"uniqueItems": true,
"items": {"type": "string", "pattern": "^[a-z][a-z0-9_-]{0,63}$"},
"description": "Frozen reason-code allowlist required whenever substitute_actor_ids is non-empty. A substitute launch must provide one of these codes."
},
"purpose": {"type": "string", "minLength": 1},
"artifact_kind": {"type": "string", "minLength": 1},
"word_limit": {"type": "integer", "minimum": 1}
Expand Down
18 changes: 18 additions & 0 deletions schemas/runtime-evidence.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@
"type": "string",
"minLength": 1
},
"scheduled_actor_id": {
"type": "string",
"minLength": 1,
"description": "Primary actor frozen in the turn schedule. Required by engine validation for a substitute turn."
},
"actor_selection": {
"type": "string",
"enum": ["primary", "substitute"],
"description": "Whether actor_id is the primary scheduled actor or a substitute preauthorized by the frozen definition."
},
"substitution_reason": {
"type": ["string", "null"],
"pattern": "^[a-z][a-z0-9_-]{0,63}$",
"description": "Null for a primary turn; otherwise the frozen reason code selected at claim time. Required by engine validation for a substitute turn."
},
"round_id": {
"type": "string",
"minLength": 1
Expand Down Expand Up @@ -173,6 +188,9 @@
{
"evidence_version": 1,
"actor_id": "worker-a",
"scheduled_actor_id": "worker-a",
"actor_selection": "primary",
"substitution_reason": null,
"round_id": "R01",
"adapter": "command",
"transport": "command",
Expand Down
8 changes: 8 additions & 0 deletions src/multi_agent_dialogue/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class PrepareContext:
# required_capabilities; runner attaches it via dataclasses.replace
# so the evidence records exactly what gated — never a re-probe.
capability_manifest: dict | None = None
substitution_reason: str | None = None

def placeholders(self) -> dict[str, str]:
return {
Expand Down Expand Up @@ -356,6 +357,13 @@ def base_evidence(self, context: PrepareContext, *, provider: str, model: str,
record = {
"evidence_version": EVIDENCE_VERSION,
"actor_id": context.actor.actor_id,
"scheduled_actor_id": context.turn.actor_id,
"actor_selection": (
"primary"
if context.actor.actor_id == context.turn.actor_id
else "substitute"
),
"substitution_reason": context.substitution_reason,
"round_id": context.turn.round_id,
"adapter": self.name,
"transport": self.transport,
Expand Down
40 changes: 38 additions & 2 deletions src/multi_agent_dialogue/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ def _next_legal_action(state: dict, turn) -> str:
)
if status == engine.STATUS_READY_FOR_OWNER or turn is None:
return "owner-decide --decision FILE"
if turn.substitute_actor_ids:
substitutes = "|".join(turn.substitute_actor_ids)
reasons = "|".join(turn.substitution_reasons)
return (
f"run --actor {turn.actor_id} --launch; or run --actor "
f"<{substitutes}> --substitution-reason <{reasons}> --launch"
)
return f"run --actor {turn.actor_id} --launch"


Expand All @@ -60,6 +67,10 @@ def _status_payload(dialogue: engine.Dialogue) -> dict:
else {
"round_id": turn.round_id,
"actor_id": turn.actor_id,
"primary_actor_id": turn.actor_id,
"allowed_actor_ids": list(turn.allowed_actor_ids),
"substitute_actor_ids": list(turn.substitute_actor_ids),
"substitution_reasons": list(turn.substitution_reasons),
"role": definition.actor(turn.actor_id).role,
"transport": definition.actor(turn.actor_id).transport,
"purpose": turn.purpose,
Expand Down Expand Up @@ -107,6 +118,10 @@ def cmd_next(args: argparse.Namespace) -> int:
"done": False,
"round_id": turn.round_id,
"actor_id": turn.actor_id,
"primary_actor_id": turn.actor_id,
"allowed_actor_ids": list(turn.allowed_actor_ids),
"substitute_actor_ids": list(turn.substitute_actor_ids),
"substitution_reasons": list(turn.substitution_reasons),
"role": actor.role,
"transport": actor.transport,
"purpose": turn.purpose,
Expand All @@ -120,9 +135,22 @@ def cmd_next(args: argparse.Namespace) -> int:
def cmd_run(args: argparse.Namespace) -> int:
dialogue = engine.Dialogue(args.dialogue)
if args.launch:
_emit(runner.launch(dialogue, args.actor, timeout=args.timeout))
_emit(
runner.launch(
dialogue,
args.actor,
timeout=args.timeout,
substitution_reason=args.substitution_reason,
)
)
else:
_emit(runner.dry_run(dialogue, args.actor))
_emit(
runner.dry_run(
dialogue,
args.actor,
substitution_reason=args.substitution_reason,
)
)
return 0


Expand Down Expand Up @@ -196,6 +224,14 @@ def build_parser() -> argparse.ArgumentParser:
)
p.add_argument("dialogue", type=Path)
p.add_argument("--actor", required=True)
p.add_argument(
"--substitution-reason",
default=None,
help=(
"required for a substitute actor; must be one of the turn's "
"frozen substitution_reasons"
),
)
group = p.add_mutually_exclusive_group()
group.add_argument("--dry-run", action="store_true",
help="show the command packet without starting any process (default)")
Expand Down
Loading