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
40 changes: 40 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,46 @@
> **Purpose:** Living document tracking all changes, decisions, and reasoning during implementation. Updated as work progresses for easy reference and review.


---

## 🔎 fix(groups): I11 final-review finding — negotiation state survives into round 2 (2026-08-08)

**Repo:** EDDI (`feat/group-i11-negotiation`)

Confirmed MAJOR from the final multi-agent review pass: `continueDiscussion` clears every other round-scoped conclusion (`synthesizedAnswer`, `decision`) with an explicit rationale, but not the persisted negotiation table — so round 2 of a NEGOTIATION group ran against round 1's proposals, and one fresh acceptance could reach "unanimous agreement" on signatures cast for a DIFFERENT question, with `tally.signedAcceptances` pointing at round 1's transcript entries. Fixed: `setNegotiation(null)` at round start, mutation-checked (the continuation test seeds a signed round-1 proposal and asserts it does not survive).


## 🔎 fix(groups): I11 PR #641 review round 1 (2026-08-08)

**Repo:** EDDI (`feat/group-i11-negotiation`)

All 12 review comments (CodeQL ×4, code-quality ×3, CodeRabbit ×5) triaged; every one accepted and fixed:

- **Stale signatures could reach "unanimity" (the real defect):** `applyMove` never withdrew an agent's earlier signature when they moved. Now: putting new terms on the table (`addProposal`, both the PROPOSAL path and a BARGAIN counter) **withdraws the mover's signatures from every other open proposal** — and, symmetrically, signing someone else's terms **supersedes the signer's own open offer**. A turn carrying BOTH `accept` and `proposal` resolves deterministically for the proposal (the accept is ignored with a WARN — new terms mean the mover is not settling). Mutation-checked: without the withdrawal, the new test's agreement check would pass on a signature its signatory abandoned.
- **Schema v4 (CodeRabbit Major, accepted):** `negotiationState` is resume-critical — an older deployment re-saving a paused v4 document would drop the table and the agreement check would run empty. `CURRENT_SCHEMA_VERSION` 3→4, identity hop (v3 docs have no negotiation state). Note: the I12 branch bumps to 4 for `runtimePhases` with the same reasoning — on merge the two v4s coalesce into one release-shape v4, which is correct: both features ship together and any pre-release pod must refuse both.
- **`NegotiationState` encapsulated** (code-quality ×2): getters return unmodifiable views; mutation goes through `addProposal`/`replaceProposal`/`addConcession` — the table cannot be edited behind the state's back.
- **Unused `phase` parameter dropped** from `applyRepeat` (call sites updated); **moderator filter null-safe** (`filter(Objects::nonNull)` before the id comparison); **CodeQL ×4** log-injection sites sanitized (`LogSanitizer` in `NegotiationEngine` ×3 + the service's skipIf log).

**Tests:** +3 (counter-proposal withdraws the stale signature AND blocks the stale agreement; accept+proposal in one turn → proposal wins; GROUP member's signature not required for unanimity) and 2 extended (superseded-proposal acceptance is inert — the case the DisplayName claimed; the scripted bargain now asserts p2 is SUPERSEDED once its owner signs p3). `NegotiationEngineTest` 15 green; group suites green.

---

## 🤝 feat(groups): I11 — NEGOTIATION style, the trade form (2026-08-08)

**Repo:** EDDI (`feat/group-i11-negotiation`)

First Wave 3 queue item. EDDI had win/lose decision forms and no **trade** form; a negotiation's output is a drafted compromise with an explicit **concession ledger** for human sign-off. The typed structure IS the anti-sycophancy mechanism.

- **Phase types `PROPOSAL` + `BARGAIN`; `skipIf="AGREEMENT_REACHED"`** — a single enum condition, deliberately NOT an expression language (a phase is skipped for a reason the engine can PROVE against the typed decision). The arbitration phase is its only user.
- **`NegotiationState` on `GroupConversation`**: proposals `{id, byAgentId, round, terms (String v1), status OPEN|SUPERSEDED, acceptedBy, acceptanceEntryIndices}` + concessions `{byAgentId, round, gaveUp, inReturnFor, refProposalId}`. Persisted with the document — a pause/resume keeps the table as it stood.
- **The BARGAIN turn contract** (`{"accept", "proposal": {"terms"}, "concessions": [{"gaveUp","inReturnFor"}]}` + free-text reasoning): three-tier parse mirroring `VoteTallyEngine` (strict → embedded → give up, FAIL_ON_TRAILING_TOKENS); an unparseable turn is prose with NO state effect (WARN, never a guessed acceptance). A concession that names nothing in return is NOT recorded — the rule is the structure, and the baked-in template says so. A new proposal supersedes the mover's own open one; the proposer signs their own terms implicitly.
- **Ledger accountability:** the open proposals + concession ledger are appended to every PROPOSAL/BARGAIN turn (and to negotiation SYNTHESIS turns — arbitration and final synthesis quote the record). Appended by `NegotiationEngine.appendStateIfRelevant` at the two input-build sites rather than templated, because the state lives on the conversation, which `buildPhaseInput` deliberately does not see.
- **Agreement**: all non-moderator participants signed the same OPEN proposal → the bargaining phase's repeats end early through the SAME `PhaseOutcome.endRepeats` plumbing convergence uses, and `DecisionRecord{AGREEMENT, method="negotiation"}` carries `tally.signedAcceptances` — each signatory mapped to the transcript index of their (already signed) acceptance entry. The entries ARE the co-signatures; no new crypto. `decision_reached` fires (its F3 event finally has a second producer).
- **Preset `NEGOTIATION`** (① Positions & Interests, PARALLEL+NONE — interests enable integrative trades ② Opening Proposals ③ Bargaining, repeats=maxRounds ④ Arbitration, MODERATOR + `skipIf` + its own TEMPLATE_ARBITRATION — the default synthesis template asks for a balanced summary, an arbitrator DECIDES ⑤ Synthesis). Arbitration that RUNS records its conclusion as `DecisionRecord{VERDICT, method="arbitration"}` (never overwriting an existing decision).
- Enum additions are compat-safe; `describe_discussion_styles` (REST) gained the entry via an exhaustive switch the compiler flagged.

**Tests (12 new in `NegotiationEngineTest` + preset shape + 2 enum pins; `engine.internal` + `configs.groups` suites 1694 green; checkstyle clean):** parse tiers incl. the JSON-plus-reasoning form; concession-must-name-return; implicit self-signature with the authoring entry index; supersession; unknown/superseded acceptance + unparseable turn inertness; ledger accumulation with round + refProposal attribution; unanimous acceptance with signed indices asserted exactly; partial acceptance ≠ agreement; arbitration records once and never overwrites; ledger rendering into the turn (and its no-op paths); and the plan's **scripted 3-round bargain converging in round 3** — propose → counter+concede → sign — as living documentation of the protocol.

---

## 🔀 merge: bring `origin/main` (PR #627 HITL request pinning) into the branch (2026-08-07)
Expand Down
32 changes: 32 additions & 0 deletions docs/group-conversations.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Group Conversations enable multiple agents to discuss a question. Each agent par
| `DELPHI` | Anonymous rounds → convergence → Synthesis | Forecasting, reducing groupthink |
| `DEBATE` | Pro → Con → Rebuttals → Judge | Trade-off analysis, comparisons |
| `TASK_FORCE` | Plan → Execute → Verify → Synthesis | Structured task decomposition, parallel execution |
| `NEGOTIATION` | Positions → Proposals → Bargaining → (Arbitration) → Synthesis | Surfacing trade-offs, drafting compromises |
| `CUSTOM` | Define your own phases | Any workflow |

## Quick Start (MCP)
Expand Down Expand Up @@ -160,6 +161,37 @@ Both caps are enforced independently: `maxPerTurn` bounds a runaway single turn,
discussion cap counts only agent-filed tasks, so a large planned backlog does not
exhaust it. A rejected call does not consume the per-turn budget.

## Negotiation (I11)

EDDI's other decision forms are win/lose; `NEGOTIATION` is the **trade** form —
a process for surfacing trade-offs whose output is a drafted compromise with an
explicit **concession ledger** for human sign-off.

The preset: ① *Positions & Interests* (parallel, context-free — interests
enable integrative trades) ② *Opening Proposals* ③ *Bargaining* (repeats =
`maxRounds`, exits early on agreement) ④ *Arbitration* (moderator; **skipped
entirely** when an agreement was reached — `skipIf: "AGREEMENT_REACHED"`, the
single deterministic skip condition) ⑤ *Synthesis*.

- A **BARGAIN** turn is a typed move: `{"accept": "<proposalId>"|null,
"proposal": {"terms": "..."}|null, "concessions": [{"gaveUp": "...",
"inReturnFor": "..."}]}` plus free-text reasoning. Three-tier parse; an
unreadable turn is prose with no state effect.
- The typed structure is the anti-sycophancy mechanism: an acceptance must name
a specific proposal id, a concession that names nothing in return is **not
recorded**, and the open proposals + ledger are quoted into every turn — the
record the outcome will cite.
- A new proposal supersedes the mover's own open one (one live offer per
agent); the proposer signs their own terms implicitly.
- **Agreement** = every non-moderator participant signed the same open
proposal. The bargaining phase ends its repeats early and the conversation
carries `decision: {type: "AGREEMENT", method: "negotiation"}` whose
`tally.signedAcceptances` maps each signatory to the transcript index of
their signed acceptance entry — the (already signed) entries are the
co-signatures; no new crypto.
- No agreement → the arbitration runs and its conclusion becomes
`decision: {type: "VERDICT", method: "arbitration"}`.

## Nested Groups (Group-of-Groups)

Members can be other groups. The sub-group runs its own discussion and its synthesized answer becomes the member's response.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ public enum DiscussionStyle {
DEBATE,
/** Collaborative task accomplishment: plan → execute → verify → synthesis. */
TASK_FORCE,
/**
* Trade, not win/lose (I11): positions & interests → opening proposals →
* bargaining with a concession ledger → arbitration (skipped once an agreement
* is reached) → synthesis.
*/
NEGOTIATION,
/** User defines phases manually. */
CUSTOM
}
Expand All @@ -208,7 +214,7 @@ public enum DiscussionStyle {
*/
public record DiscussionPhase(String name, PhaseType type, String participants, TurnOrder turnOrder, ContextScope contextScope,
boolean targetEachPeer, String inputTemplate, int repeats, boolean requiresApproval, ConvergenceConfig convergence,
boolean allowAbstention) {
boolean allowAbstention, PhaseSkipCondition skipIf) {

/**
* Convenience constructor with defaults: participants=ALL,
Expand All @@ -219,6 +225,18 @@ public DiscussionPhase(String name, PhaseType type) {
this(name, type, "ALL", TurnOrder.SEQUENTIAL, ContextScope.FULL, false, null, 1, false);
}

/**
* Backward-compatible constructor without {@code skipIf} (I11) — {@code null}
* means the phase always runs, which is the behavior of every phase that
* predates I11.
*/
public DiscussionPhase(String name, PhaseType type, String participants, TurnOrder turnOrder, ContextScope contextScope,
boolean targetEachPeer, String inputTemplate, int repeats, boolean requiresApproval, ConvergenceConfig convergence,
boolean allowAbstention) {
this(name, type, participants, turnOrder, contextScope, targetEachPeer, inputTemplate, repeats, requiresApproval, convergence,
allowAbstention, null);
}

/**
* Backward-compatible constructor without requiresApproval.
*/
Expand Down Expand Up @@ -323,7 +341,31 @@ public enum PhaseType {
/** Task execution by assigned agents. */
EXECUTE,
/** Verification of task results. */
VERIFY
VERIFY,
/**
* An opening offer in a negotiation (I11) — the whole turn is the proposal's
* terms.
*/
PROPOSAL,
/**
* A bargaining turn (I11): accept an open proposal, counter with a new one,
* and/or record concessions — a typed JSON contract, parsed by
* {@code NegotiationEngine}.
*/
BARGAIN
}

/**
* The one phase-skip condition (I11) — deliberately a single enum, not an
* expression language: deterministic governance means a phase is skipped for a
* reason the engine can PROVE, not one an expression evaluates to.
*/
public enum PhaseSkipCondition {
/**
* Skip when the discussion already carries an AGREEMENT decision — the
* arbitration phase of a negotiation is only needed when bargaining failed.
*/
AGREEMENT_REACHED
}

public enum TurnOrder {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,63 @@ private DiscussionStylePresets() {
]
```""";

/**
* The opening offer (I11). The whole reply becomes the proposal's terms —
* deliberately prose, not JSON: an opening position is authored, not parsed.
*/
public static final String TEMPLATE_PROPOSAL = """
A negotiation is underway on:
"{question}"

{#if previousResponses}
Positions and interests stated so far:
{#for entry in previousResponses}
— {entry.speaker}: "{entry.content}"
{/for}
{/if}

As {displayName}, state your OPENING PROPOSAL: concrete terms the \
others could accept. Your entire reply is the proposal.""";

/**
* The bargaining contract (I11). The baked-in rules are the anti-sycophancy
* mechanism: an acceptance must name a specific proposal id, and a concession
* that does not name what was received in return is not recorded. The current
* table (open proposals + concession ledger) is appended to this prompt by
* {@code NegotiationEngine.appendStateIfRelevant}.
*/
public static final String TEMPLATE_BARGAIN = """
A negotiation is underway on:
"{question}"

As {displayName}, make your bargaining move. Reply with JSON in this exact shape \
(any field may be null/empty), followed by your free-text reasoning:
{"accept": "<proposalId>"|null, "proposal": {"terms": "..."}|null, "concessions": [{"gaveUp": "...", "inReturnFor": "..."}]}

Rules:
- Do not accept any proposal that fails your stated interests.
- Every concession must name what you received in return — unreciprocated concessions are not recorded.
- The ledger below is the record — it will be quoted in the outcome.""";

/**
* Arbitration (I11): bargaining ended without unanimous acceptance, so the
* moderator decides. Only rendered when the phase actually runs — an agreement
* skips it entirely ({@code skipIf=AGREEMENT_REACHED}).
*/
public static final String TEMPLATE_ARBITRATION = """
You are arbitrating a negotiation on:
"{question}"

The parties bargained but did NOT reach unanimous agreement. The full \
transcript is your record:
{#for entry in transcript}
[{entry.phaseName}] {entry.speaker}: "{entry.content}"
{/for}

As the arbitrator, decide the outcome. Weigh the stated interests, the \
open proposals and the concession ledger (appended below); state your \
decision and its reasoning plainly.""";

// Template lookup by phase type
private static final Map<PhaseType, String> DEFAULT_TEMPLATES = Map.ofEntries(
Map.entry(PhaseType.OPINION, TEMPLATE_OPINION_INDEPENDENT),
Expand All @@ -284,7 +341,9 @@ private DiscussionStylePresets() {
Map.entry(PhaseType.SYNTHESIS, TEMPLATE_SYNTHESIS),
Map.entry(PhaseType.PLAN, TEMPLATE_PLAN),
Map.entry(PhaseType.EXECUTE, TEMPLATE_EXECUTE),
Map.entry(PhaseType.VERIFY, TEMPLATE_VERIFY));
Map.entry(PhaseType.VERIFY, TEMPLATE_VERIFY),
Map.entry(PhaseType.PROPOSAL, TEMPLATE_PROPOSAL),
Map.entry(PhaseType.BARGAIN, TEMPLATE_BARGAIN));

/**
* Returns the default template for a given phase type.
Expand Down Expand Up @@ -318,10 +377,37 @@ public static List<DiscussionPhase> expand(DiscussionStyle style, int maxRounds)
case DELPHI -> delphi(rounds);
case DEBATE -> debate();
case TASK_FORCE -> taskForce();
case NEGOTIATION -> negotiation(rounds);
case CUSTOM -> List.of();
};
}

// --- NEGOTIATION (I11) ---

/**
* ① Positions & Interests — PARALLEL and context-free, so parties state genuine
* interests before anchoring on each other (interests are what enable
* integrative trades). ② Opening Proposals. ③ Bargaining, repeated
* {@code rounds} times — the repeat loop exits early on unanimous acceptance. ④
* Arbitration — skipped entirely when an agreement was reached. ⑤ Synthesis —
* quotes the ledger.
*/
private static List<DiscussionPhase> negotiation(int rounds) {
List<DiscussionPhase> phases = new ArrayList<>();
phases.add(new DiscussionPhase("Positions & Interests", PhaseType.OPINION, "ALL", TurnOrder.PARALLEL,
ContextScope.NONE, false, null, 1));
phases.add(new DiscussionPhase("Opening Proposals", PhaseType.PROPOSAL, "ALL", TurnOrder.SEQUENTIAL,
ContextScope.FULL, false, null, 1));
phases.add(new DiscussionPhase("Bargaining", PhaseType.BARGAIN, "ALL", TurnOrder.SEQUENTIAL,
ContextScope.FULL, false, null, rounds));
phases.add(new DiscussionPhase("Arbitration", PhaseType.SYNTHESIS, "MODERATOR", TurnOrder.SEQUENTIAL,
ContextScope.FULL, false, TEMPLATE_ARBITRATION, 1, false, null, false,
AgentGroupConfiguration.PhaseSkipCondition.AGREEMENT_REACHED));
phases.add(new DiscussionPhase("Synthesis", PhaseType.SYNTHESIS, "MODERATOR", TurnOrder.SEQUENTIAL,
ContextScope.FULL, false, null, 1));
return phases;
}

// --- ROUND_TABLE ---

private static List<DiscussionPhase> roundTable(int rounds) {
Expand Down
Loading
Loading