diff --git a/docs/changelog.md b/docs/changelog.md index fd9d4ffc60..d2f80a1af0 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,492 @@ > **Purpose:** Living document tracking all changes, decisions, and reasoning during implementation. Updated as work progresses for easy reference and review. +--- + +## 🔎 fix(groups): pre-merge deep review — facilitator HITL bypass, CALL_VOTE guards, metric cardinality, template honesty (2026-08-08) + +**Repo:** EDDI (`feat/group-i10-templates`) + +Final pre-merge review (20-agent workflow: 6 focused reviewers over the facilitator subsystem — which never received an external bot review (#643 rate-limited) — the I10 templates and the merge seams; every non-minor finding independently double-verified). Six confirmed findings, all fixed: + +1. **CRITICAL — facilitator escalation bypassed the phase-boundary HITL gate.** A deferred EACH_REPEAT `ESCALATE_HUMAN` at a phase's final repeat returned from inside the repeat loop before the `requiresApproval` gate: the phase's mandatory human approval (and the TASK-granularity awaiting-task pause) never fired, and the escalation answerer has no REJECT path — a silent compliance bypass. Now the boundary gate supersedes the escalation: when the just-completed phase's gate will pause, the escalation is suppressed with a FACILITATION entry that preserves the facilitator's question for the approver, and control falls through to the normal gate. Mid-phase escalations are unaffected (no gate is owed yet). Pinned by `escalate_atTheBoundaryOfAnApprovalGatedPhase_theApprovalGateWins`. +2. **CALL_VOTE could clobber a recorded decision.** Unlike END_PHASE/EXTEND_PHASE it had no `phaseEndedBySignal` guard, and the inserted vote's tally unconditionally replaces `gc.decision` — destroying a signed AGREEMENT (breaking `skipIf=AGREEMENT_REACHED`) or a debate VERDICT (resurfacing the raw-judgment-JSON defect). CALL_VOTE is now rejected when the phase ended by signal or a DecisionRecord already exists. +3. **Unbounded metric label cardinality.** `recordRejection` used the raw LLM-authored move string as the `move` tag of `eddi_group_facilitator_moves_total` — every hallucinated name a new Prometheus time series for the JVM's lifetime (and injectable via transcript content echoed in the briefing). The tag is bounded to enum names + `UNKNOWN`; `rawMove` is also length-capped at parse. +4. **The deferred EACH_REPEAT branch was untested.** ESCALATE/CALL_VOTE were e2e-tested only at EACH_PHASE; the mid-phase resume arithmetic (same phase, repeat+1) and the boundary arm (phase+1, repeat 0) had zero coverage. Three e2e tests added. +5. **negotiation-table.json promised a human-arbiter mode that does not exist** — a human principal as arbiter would neither warn at save nor pause synthesis (the arbiter is only `moderatorAgentId`, never a HUMAN roster member); phases would be silently SKIPPED. The manifest now states the arbiter must be a deployed agent and points to decision-board / hitlConfig for human decision-makers. +6. **research-pod.json silently degraded its context window** — `summarizeOverflow` defaults true but no summarizer model is named, tripping the save-time warning on a SHIPPED template and truncating instead of summarizing at runtime. Now explicitly `false` (honest truncation). + +Minor also fixed: the human-input 409 body no longer misstates config-drift as "not awaiting human input" — the two static drift messages pass through so the operator learns the config changed instead of retrying forever. + +Suites: FacilitatorEngineTest 42 (+3), Facilitator e2e 12 (+3), templates/REST/HITL 96 — all green. + +--- + + +## 🔎 fix(groups): I8 review round 3 — retro ceilings + creation-ordered FIFO (2026-08-08) + +**Repo:** EDDI (`feat/group-i8-retro-memory`) + +Two accepted CodeRabbit findings, one rebutted: + +1. **RetroConfig ceilings (major)** — the compact ctor accepted any positive int, so `Integer.MAX_VALUE` unbounded the per-run write count and the retained-lesson set. Hard ceilings: `CEILING_MAX_PER_RUN` 20, `CEILING_MAX_STORED` 500 (clamped, not rejected — same normalization style as the rest of the record). +2. **FIFO vs reharvest (major)** — eviction used the `most_recent` recall order (sorted by `updatedAt`); reharvesting an existing lesson refreshes `updatedAt`, so an old-but-reharvested lesson could shield itself while a later-CREATED lesson got evicted. Eviction now sorts by `createdAt` (which the store `setOnInsert`s — the stable insertion stamp), nulls oldest-first. Regression test reharvests an old lesson before exceeding the cap and asserts the oldest-created is the one evicted. +3. **Rebutted: RETRO entries lost on mid-repeat HITL resume** — on this branch nothing ever creates a speaker-level `ResumePoint` (producers are I6 human turns and I12 escalations, on other branches), so a RETRO phase cannot resume mid-repeat here. On the integration branch, where producers exist, the I6 `pausedRepeatSliceBase` fix restores the true repeat base at top-of-repeat before `repeatEntries` is sliced — the RETRO harvest reads that same slice, so pre-pause lessons are preserved (pinned by the HumanPauseRepeatSlice tests). +## 🔎 fix(groups): I13 review round 3 — revision parse guard, convergence limit, test pins (2026-08-08) + +**Repo:** EDDI (`feat/group-i13-standing-teams`) + +Six findings from the CodeRabbit/CodeQL round on the round-2 push, all accepted: + +1. **NumberFormatException in casRevision (CodeQL, both #644 and #645)** — `Long.parseLong` on a persisted value; a corrupt revision surfaced as an uncaught runtime exception (bare 500 at REST). Guarded: non-numeric revision throws the method's declared `ResourceStoreException` with the value named; the instance keeps the corrupt value for diagnosis. +2. **Convergence limit (major)** — `findWorkspaceIds` fetched at most 2 ids; with 3+ concurrent creators, different racers could see different subsets and compute different survivors, never converging. Limit raised to 50 so every realistic racer set fits one query. +3. **Javadoc detachment (minor)** — `casRevision` was inserted between `casRunningDiscussion`'s Javadoc and its declaration, silently detaching it. Reordered; the claim Javadoc is re-attached. +4. **Legacy null-revision window (minor)** — documented on `IGroupWorkspaceStore.casRevision`: one unconditional stamp per pre-revision document, CAS'd forever after. +5. **Deletion-order pin (minor)** — the schedule-retirement test now uses `InOrder`, protecting the crash-recoverable order (schedules before workspace), not just the calls. +6. **Write-path pins (minor)** — the backlog rejection tests assert `never().casRevision(any())` (the actual write path) alongside the legacy `update` pin; the lost-settle test uses `verifyNoMoreInteractions` so the failed conditional release is provably the ONLY store interaction. + +--- + +## 🔎 fix(groups): I13 review round 2 — workspace concurrency + schedule lifecycle (2026-08-08) + +**Repo:** EDDI (`feat/group-i13-standing-teams`) + +Five accepted findings from the CodeRabbit/CodeQL round on PR #644: + +1. **Atomic backlog adds (major)** — both backlog-add surfaces (REST `addBacklogTask`, MCP `add_team_task`) validated the cap/duplicate rules against their own snapshot and then issued a whole-document `update`, so two concurrent adds could both pass under the cap and the later write dropped the earlier task. New optimistic-concurrency primitive: `GroupWorkspace.revision` (string stamp) + `IGroupWorkspaceStore.casRevision` (revision-conditional store via `storeIfFieldEquals`, bump-in-write, loser restores its stamp). Both surfaces run a 3-attempt read-validate-mutate-CAS loop; exhaustion is an honest 409/error telling the caller to retry. Pre-revision documents get stamped by one plain write, then CAS'd forever after. +2. **Duplicate workspace documents (major)** — `readOrCreate` read-then-inserted with no unique constraint (the storage abstraction has none), so concurrent creators could each insert a document and split subsequent writes. Now: after inserting, re-query; every racer that does not hold the deterministic survivor (lexicographically smallest id = earliest ObjectId) deletes its own insert and adopts the survivor. `find()` picks the same survivor when duplicates linger, so readers and racers always agree. +3. **Orphaned schedule on failed cadence write (major)** — `addCadence` created the `ScheduleConfiguration` before the workspace write; a failed write left an enabled schedule no cadence names, firing "cadence no longer exists" forever with no delete path. The failed write now compensates by deleting the schedule. +4. **Group deletion left cadence schedules behind (major)** — permanent group deletion removed only the workspace; enabled schedules kept firing "No workspace exists". `RestAgentGroupStore` now retires every cadence's schedule BEFORE deleting the workspace (crash between the two leaves the recoverable order). +5. **Log sanitization (minor + CodeQL 478/480)** — the run-claim disappearance warning sanitizes `groupId`; the same treatment in the new casRevision path. + +Tests: GroupWorkspaceStoreTest (new, 5: CAS bump/restore/legacy-stamp, duplicate convergence in readOrCreate and find), RestGroupWorkspaceTest 14 (+2: lost-CAS retry/exhaustion, schedule cleanup on failed write), McpGroupToolsTest 53 (+1 retry/exhaustion), RestAgentGroupStoreTest (+1 schedule retirement). All green. + +--- + +## 🔎 fix(groups): I8 review round 2 — retro cap semantics + event contract (2026-08-08) + +**Repo:** EDDI (`feat/group-i8-retro-memory`) + +Four accepted findings from the CodeRabbit round on the stacked PR (#644, which carries this branch): + +1. **Template quoted the wrong cap (major)** — the RETRO prompt always rendered `DEFAULT_MAX_PER_RUN` (3), so a group configured above it could never obtain its configured number of lessons. `buildPhaseInput` gained a `RetroConfig` parameter (config-less overloads keep the default); all three agent-turn call sites in `PhaseExecutionEngine` pass `config.getRetroConfig()`. RetroEngine's parse-time enforcement is unchanged and remains the server-side limit. +2. **Per-entry cap multiplied (major)** — `harvest` applied `maxLessonsPerRun` to each RETRO transcript entry, so a multi-speaker or multi-repeat retro could store `cap × entries`. Now a per-harvest `remaining` allowance shared across entries. +3. **Missing zero-count event (minor)** — the null-memory-store branch returned before `retro_recorded`; it now fires the event with `lessonsStored = 0`, honoring the contract that a RETRO phase always emits it. +4. **Fixture didn't cross users (minor)** — `personalEntriesNeverCrossUsers` built its "other user's" entry with the shared helper that stamps USER; it now genuinely belongs to `user-2`. + +Tests: RetroEngineTest 8 (+2: per-harvest cap, null-store zero event), GroupContextBuilderTest 41 (+1: configured cap quoted, default fallback), PhaseExecutionEngineTest stub widened to the new arity. All green. +## 🔎 fix(groups): I6 review round — schema bump, approver full-view scope, persist-time assertion (2026-08-08) + +**Repo:** EDDI (`feat/group-i6-human-members`) + +Three accepted CodeRabbit findings on PR #640 (all against the pausedRepeatSliceBase fix commit): + +1. **Schema version (major)** — `pausedRepeatSliceBase` is persisted state the resumed leg depends on, but `CURRENT_SCHEMA_VERSION` stayed 3. Bumped to 4 (no migration entry: Jackson defaults legacy documents to -1, the exact pre-v4 behavior). On the integration branch v4 is the release shape shared with I11's `negotiationState` and I12's `runtimePhases`. +2. **Approver transcript scope (major, security)** — the `detail=full` gate used the shared `paused` predicate, which also covers `AWAITING_HUMAN_INPUT`; an `eddi-approver` could read the full transcript of a discussion merely waiting on a human member's turn. Both surfaces (REST `getGroupApprovalStatus`, MCP `get_group_approval_status`) now gate the approver window on a dedicated `awaitingApproval` predicate; summary fields keep the wider one. Regression tests on both surfaces (approver + human-turn pause → 403/FORBIDDEN). +3. **Persist-time assertion (minor)** — the mid-repeat pause test asserted only the in-memory instance; a captor would hold the same mutable object, so the test now records `pausedRepeatSliceBase` inside the `update()` stub at persist time and asserts the last persisted value. + +--- +## 🔎 fix(groups): I13 + HITL fixes from the final cross-branch review (2026-08-08) + +**Repo:** EDDI (`feat/group-i13-standing-teams`) + +Fixes from the 23-agent final review (5 dimensions, every non-minor finding adversarially double-verified; 9 confirmed across all branches — the four below are the ones on this branch's surface): + +1. **Settle race (major/concurrency)** — `TeamCadenceService.settle()` released the claim with an **unconditional** `update()`, so a writeback racing a concurrent reconcile (schedule fire on another pod, or the REST read-repair) could clobber a *newer* claim, orphaning that discussion's outcomes. Now conditional: `settle(workspace, gc, settledDiscussionId)` releases via `casRunningDiscussion(workspace, settledDiscussionId)`; both writebacks capture the id before clearing and return the verdict; a lost race drops the caller's mutations (`writeback_lostSettleRace_dropsMutations` pins it). Reconcile propagates the verdict so a losing caller treats the workspace as busy. +2. **Cost ceiling lost on HITL resume (major/cost-bounds)** — `GroupConversation.inheritedCostCeiling` was `@JsonIgnore transient`, so a cadence run that paused for approval resumed with **no ceiling**. Now a persisted field (documented as a review finding in the Javadoc). +3. **Backlog write caps bypassed (major/security)** — REST `addBacklogTask` and MCP `add_team_task` skipped the caps the agent tool surface enforces: subject ≤ `MAX_AGENT_TASK_SUBJECT_LENGTH` (200), description ≤ `MAX_AGENT_TASK_DESCRIPTION_LENGTH` (4000), and duplicate subjects (case-insensitive) now 409/error — writeback matches outcomes **by subject**, so duplicates made outcome attribution ambiguous. Plus two bound minors: cadence count capped at 20/workspace, `inputTemplate` ≤ 4000 chars. +4. **Unbounded failure-feedback growth (major/cost-bounds)** — the COMPLETED writeback appended each failed run's **entire agent output** to the persisted task description every run. New `appendFeedbackBounded`: 500-char per-run slice, total trimmed from the front (oldest first) to the shared 4000-char description cap. + +Also on this branch's surface: the **claim-CAS exception path** now cancels the just-started discussion before rethrowing (previously it leaked an unclaimed discussion running to completion with outcomes no writeback collects), the template-failure log sanitizes the exception message, and `GroupHitlCoordinator`'s executor-saturated resume rollback uses remove-and-recheck (`removeTokenAndConvertIfSignalled`) like every sibling rollback path — a cancel signalled between token registration and the rollback was silently dropped, leaving a "cancelled" discussion stuck AWAITING_APPROVAL (regression test added, mutation-verified: reverting the fix fails it). + +**Rebutted (deliberate, not fixed):** MCP `list_team_backlog` requiring `eddi-viewer` while REST listing requires editor — consistent with the read_group/list_groups viewer convention across all MCP group read tools. + +**Tests:** TeamCadenceServiceTest 17 (+3), RestGroupWorkspaceTest 12 (+3), McpGroupToolsTest 52 (+2), GroupHitlCoordinatorTest 16 (+1). All green. + +--- +## 🔎 fix(groups): I12 final-review findings — checkpoint runs before the decision block (2026-08-08) + +**Repo:** EDDI (`feat/group-i12-facilitator`) + +Two confirmed MAJORs from the final multi-agent review pass, one root cause: the EACH_REPEAT facilitator checkpoint sat AFTER the last-repeat decision block. + +- **END_PHASE skipped the decisions:** it fired only mid-phase (where `lastRepeat` is false) and took a plain `break` past the block — a VOTE phase it ended never tallied its cast ballots; verdicts, dissent rounds and retro harvests were skipped the same way. +- **EXTEND_PHASE at a final repeat re-ran them:** the block had already fired for that repeat, and the extension made the next repeat "final" again — duplicate dissent rounds, `decision_reached` twice, the tally overwritten. + +**Fix:** the consult now runs after convergence but BEFORE `lastRepeat` is computed, with effects split by kind — END_PHASE folds into the phase outcome (the block sees a real phase end and records everything), EXTEND_PHASE applies immediately (deferring the block to the true final repeat), and INSERT_VOTE/ESCALATE are stashed and applied after the block, so an escalation on a final repeat cannot skip that repeat's decisions on its way out. Two new mutation-check E2Es: END_PHASE on a VOTE phase still tallies (fails against the old `break`), and always-EXTEND on a dissent-recording SYNTHESIS runs exactly one dissent round (fails against the old ordering). +## 🔎 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): I6 final-review finding — mid-repeat pause loses the repeat slice (2026-08-08) + +**Repo:** EDDI (`feat/group-i6-human-members`) + +Confirmed CRITICAL from the final multi-agent review pass (2 independent verifiers traced it): a human turn pauses MID-repeat, after other speakers already appended this repeat's entries — but the resumed leg recomputed `transcriptSizeBeforeRepeat` from the current transcript size, so the repeat slice covered only post-pause entries. Every consumer of that slice silently lost the pre-pause contributions: the convergence check on this branch, and (on the integration tree) VOTE tallies missing every agent ballot cast before the human's — a wrong election, reported as legitimate. + +**Fix:** new persisted `pausedRepeatSliceBase` on `GroupConversation` (−1 = unset; legacy documents keep the old recompute), written when the human-turn pause commits (the catch site has the true base in scope) and consumed exactly once with the same read-and-clear discipline as the speaker bookmark. Tests: the pause persists the base pointing at the top of the repeat (fails without the write), and a resumed leg consumes it exactly once (fails without the consume). +## 🧩 feat(groups): I10 — preset templates on the all-features integration branch (2026-08-08) + +**Repo:** EDDI (`feat/group-i10-templates` — the integration branch: I14+I6+I12 base, merged with I11, I18, I8+I13, I17 and the pre-feature defects branch (N1/N2/N3/I9)) + +Final queue item, shipped last by design so templates only reference features that exist. This branch is ALSO the proof the user story demanded: every feature branch merged into one tree, compiled clean, full suites green. + +- **Integration merges, verified per the big-merge memory:** 5 sequential merges with a clean compile + suite run after each. Real semantic resolutions: `DiscussionPhase` unified to 13 components (`voteConfig` 12th + `skipIf` 13th) with BOTH 12-arg compat ctors kept so each branch's call sites compile unchanged; `PhaseType` unions to 15 (VOTE + PROPOSAL + BARGAIN + RETRO — pins updated); schema v4's Javadoc now names BOTH resume-critical fields (I12 `runtimePhases`, I11 `negotiationState`); the SYNTHESIS decision block runs debate verdicts AND I11 arbitration with one late `decision_reached` firing after the dissent round (an `arbitrated` flag is true only when THIS call set the decision, so the event never re-announces an earlier phase's); `AgentGroupStore.create/update` runs all five validators; every listener/SSE/Slack surface carries all events. 3880 tests, 0 failures (27 known environmental SlackWebApiClient socket errors). +- **Cross-branch defect the integration run caught (the reason this branch exists):** I9's windowing overload (defects branch) and I6's human-turn prompt render (human-members branch) had never met — merged, agents got windowed context while a HUMAN member's rendered prompt silently used the unwindowed compat overload, breaking I6's "the human sees exactly what an agent speaker would" contract. Both human-prompt sites now pass `config.getContextWindow()`; the I6 blindness tests verify the windowing overload. The same resolution applies when #636 and #640 merge to main. +- **Templates:** `src/main/resources/group-templates/` (index + 5 JSONs, the initial-agents classpath pattern): `research-pod` (DELPHI-style + convergence + retro + windowing + ceiling), `editorial-team` (shared artifact + CAS updates + dissents), `ops-task-force` (BID assignment + agent-filed tasks + recruitment), `decision-board` (HUMAN director deliberates and votes; ties to the chair — **deviation upheld:** `HUMAN_DECIDES` tie-breaking stays save-time rejected; its resume machinery (a pending ballot the human's answer must parse back into the tally) is real work, not the "small follow-up" the I14 note hoped, and shipping a silently-degrading enum value would be a lie), `negotiation-table` (typed bargaining; human-arbiter option documented). Placeholder mechanism is deliberately minimal: member `agentId`s are `$role` markers, substitution is the ONLY templating — what you read is what the store validates. +- **`GroupTemplateService`**: classpath loading (one bad template logs loudly and skips, never breaks startup), manifest listing, `instantiate(templateId, name, roleAssignments)` failing loudly and completely on missing/unknown roles BEFORE building anything. **REST** `/groupstore/templates` (list/read/instantiate → `RestAgentGroupStore.createGroup`, the normal path — a template earns no validation bypass). **MCP** `list_group_templates` + `create_group_from_template` (whitelisted). +- Store validators (`validateVotePhases`, `humanMemberProblems`, `validateFacilitator`) widened to public — they are now the cross-package save-time validation surface the template integration test exercises. + +**Tests (+13):** every template loads, instantiates with dummy assignments and passes the ENTIRE save-time matrix (HITL + vote + human + facilitator + artifact validators) — the plan's designated integration test of all Wave 1–3 config surfaces; placeholder-free rosters; decision-board's HUMAN member survives; negotiation preset expands; missing/unknown/unknown-template errors name what is wrong; REST instantiation captured through the store path with 400s saving nothing; MCP filter pins green. + + +## 🔎 fix(groups): I12 PR #643 review round 1 (2026-08-08) + +**Repo:** EDDI (`feat/group-i12-facilitator`) + +All 5 findings (CodeQL ×4 incl. 1 high, code-quality ×1) accepted and fixed: + +- **TOCTOU (high):** the briefing's task summary called `taskList.getTasks()` twice — an emptiness check then a re-read, each under its own monitor acquisition. One snapshot, streamed once. +- **`facilitatorExtensions` encapsulated** (the NegotiationState treatment): the getter returns an unmodifiable view; mutation goes through `recordFacilitatorExtension`/`clearFacilitatorExtensions`/`facilitatorExtensionCount` — the extension caps cannot be edited behind the conversation's back. Engine, service, lifecycle-ops and tests rewired. +- **Log injection ×3:** the two budget-skip debug logs and the END_PHASE info log sanitize their caller-influenced values (conversation id, groupId, phase name). + +Suites: facilitator + service + conversation tests (154) green. + +--- + +## 🎛️ feat(groups): I12 — facilitator with bounded moves (2026-08-08) + +**Repo:** EDDI (`feat/group-i12-facilitator` — stacked: I14 branch + merge of I6, because the moves ARE those features) + +Eighth queue item. The plan's resolution of "adaptive orchestration" vs deterministic governance: a facilitator agent is briefed at checkpoints and **selects from config-enumerated moves** — validated, capped, audit-logged; every failure degrades to CONTINUE. + +- **Integration merge first (big-merge memory applied):** merged `feat/group-i6-human-members` into a branch cut from `feat/group-i14-voting`; 5 conflicted files resolved by keeping both sides (AgentGroupStore create/update now run vote + human + facilitator validation; Slack listener keeps tally block AND human notice; both test blocks; both doc sections; both changelog entries). Verified per the memory: clean compile, 2912 tests green across `engine.internal`+`configs.groups`+`engine.hitl`+`engine.mcp`, hot files (GroupConversationService/PhaseExecutionEngine/AgentGroupConfiguration) spot-checked for both features, enum pins consistent (PhaseType 12, MemberType 3, DiscussionStyle 7 — no NEGOTIATION here, that's I11's branch). +- **Config:** `FacilitatorConfig {enabled=false, agentId (required when enabled), allowedMoves (default [CONTINUE] — an enabled-but-unconfigured facilitator is a pure observer), checkAfter=EACH_PHASE|EACH_REPEAT, maxMovesPerDiscussion=10 (non-CONTINUE only), escalateTo}`. Save-time matrix (`AgentGroupStore.validateFacilitator`): enabled needs agentId; END_PHASE/EXTEND_PHASE + EACH_PHASE rejected (they act on remaining repeats — a boundary checkpoint has none, the config could only produce noise); ESCALATE_HUMAN needs escalateTo; cap ≤ 100. +- **`FacilitatorEngine` (new R1-style collaborator):** compact briefing (position, budget arithmetic, roster, per-type entry counts, capped excerpts — bounded-by-construction, asserted in a test with a 50k-char transcript); the consult runs under the judge precedent (own `__facilitator` conversation key, skipped at either budget, counts a turn, cost on the I1 ledger); three-tier parse mirroring VoteTallyEngine; per-move context validation (a convergence exit is never overruled). Executed → peer-hidden FACILITATION entry + `group.facilitator` audit event + `eddi_group_facilitator_moves_total{move,outcome}`; rejected → CONTINUE + WARN + FACILITATION entry recording the attempt (never consumes the budget); null reply/exception → CONTINUE with no entry (nothing was tried). +- **Moves:** END_PHASE breaks the repeat loop; EXTEND_PHASE rewrites the phase record with repeats+1 (≤2/phase, persisted in `facilitatorExtensions` so a pause can't refill it); CALL_VOTE builds the vote phase to I14's enforced PARALLEL+NONE shape and inserts at `phaseIdx+1`; RECRUIT mirrors RecruitAgentTool's full matrix (already-member incl. config roster, `maxRecruitedAgentsPerDiscussion`, deployed-and-ready, synchronized double-check commit); ESCALATE_HUMAN rides I6's machinery whole — new `commitFacilitatorEscalationPause` bookmarks the RESUME point (next repeat mid-phase / next phase at a boundary) with `speakerIdx=-1` so the shared `+1` advance lands at speaker 0, and the answer records as peer-visible FOLLOW_UP. +- **Runtime phase divergence (F6 bump 3→4):** the loop iterates a runtime copy; on divergence it persists to `gc.runtimePhases`, and EVERY resume surface (`resumeDiscussion`, `resolveHumanTurn` — which submissions AND timeout skips share) now resolves `effectivePhases(gc, config)` so bookmarks and drift checks compare against the list the pause was taken from. v4 is load-bearing: an older pod resuming a diverged doc would mis-index every bookmark — exactly what the newer-than-current refusal exists for (no migration entry needed; identity default). Divergence is one-off: completion clears it, and `continueDiscussion` clears defensively. +- **Checkpoint placement, both deliberate:** EACH_REPEAT after the repeat's own bookkeeping (briefing describes a settled repeat) and before the outcome break (context flags carry how it ended); EACH_PHASE AFTER the HITL gate — a gated phase's approval must never be silently skipped because a facilitator escalated first (the reverse — one missed checkpoint — is the harmless direction). + +**Tests (+39 engine, +5 store, +4 config pins/defaults, +2 coordinator, +7 service E2E; suites green):** parse tiers; every move happy/disallowed/malformed/invalid-in-context; move cap; extension cap (E2E: repeats=1 + always-extend runs exactly 3 rounds — the cap is what stops the loop); budget gates never call the model; briefing boundedness (<5k chars against a 100k transcript, no full entry content); mutation-check: un-listed move degrades to CONTINUE with zero effect and a rejection record; CALL_VOTE E2E proves the runtime insertion ran (ballots + VOTE DecisionRecord from a config with no vote phase) and completion clears `runtimePhases`; escalation E2E (pause shape on the configured principal) + coordinator submit test proving the drift check passes ONLY because effectivePhases returns the runtime list; facilitator-unavailable and CONTINUE-everywhere leave the discussion untouched. 3 existing coordinator stubs re-pointed `resolvePhases`→`effectivePhases`. + +**Files:** `AgentGroupConfiguration` (FacilitatorConfig/FacilitatorMove/FacilitatorCheckpoint), `GroupConversation` (schema v4, runtimePhases, facilitatorMoveCount, facilitatorExtensions), `FacilitatorEngine` (new), `GroupConversationService` (runtime phase list, two checkpoint sites, effectivePhases, completion clear), `GroupHitlCoordinator` (escalation pause, effectivePhases at both resume surfaces), `GroupLifecycleOps` (round-start clear), `AgentGroupStore` (validateFacilitator), `docs/group-conversations.md`, 5 test classes. + +--- + +## 🔎 fix(groups): I14 PR #638 review round 1 (2026-08-08) + +**Repo:** EDDI (`feat/group-i14-voting`) + +All 14 review comments (CodeQL ×6, code-quality ×1, CodeRabbit ×6, + enum-count CI failure) triaged; every one accepted and fixed: + +- **CI failure**: `AgentGroupConfigurationTest.phaseType_allValues` pins the enum size at 11; VOTE is the 12th. Fixed here (and the same pin fixed for RETRO on the I8 branch — the local targeted regressions missed this class; noted for future enum-touching branches). +- **The moderator tiebreak is now budget-gated** (the one real architecture defect): it is an LLM turn, and it ran unguarded after `maxTurns` was exhausted or the cost ceiling fired — the only extra call in `PhaseExecutionEngine` without the gate `checkConvergence` and `runDissentRound` both carry. `recordVoteDecision` now takes `(turnCounter, maxTurns)`, blocks the tiebreak on either budget (keeping the honest NO_DECISION), and counts the turn it does spend. +- **Losing-side dissents survive a tie-policy resolution**: the unresolved tally's record necessarily has no dissents, and `moderatorTiebreak` reused it — so the minority report vanished for exactly the closest votes. `TallyOutcome` now carries the parsed ballots; the tiebreak computes `losingDissents(ballots, chosenOption)` against ITS choice. +- **Weighted-total ties compare with an epsilon** (1e-9), not `==`: totals are sums of non-representable doubles, so 0.1+0.2 vs 0.3 — a genuine tie — silently crowned one side on the last bit. +- **Ballot weights must be finite**: NaN passes every `<` comparison and poisons the totals; infinity decides every vote alone. Save-time rejection alongside the existing `>= 0`. +- **Slack tally lines are width-bounded** (`buildPreview`, 120 chars): a LAST_SYNTHESIS option can be a paragraph, and six of those pushed the whole decision message past Slack's limit — `postSafe` then swallowed the loss, winner and all. +- **CodeQL log injection ×6** in `PhaseExecutionEngine` sanitized (`LogSanitizer` on conversation/phase/outcome/exception values); the flagged useless null check in `moderatorTiebreak` removed (control flow guarantees non-null there). + +**Tests:** +6 (floating-point tie; outcome-carries-ballots + dissent-vs-choice; NaN/∞ weight rejection ×2 scenarios; tiebreak blocked at budget spends nothing; tiebreak within budget counts its turn AND carries the loser's dissent — the last one fails against the pre-fix code on both the counter and the dissent assertions). `engine.internal` + `configs.groups` suites: 1711 green; checkstyle clean. + +--- + +## 🗳️ feat(groups): I14 — voting with structural ballot independence (2026-08-08) + +**Repo:** EDDI (`feat/group-i14-voting`) + +Second Wave 2 queue item. A `VOTE` phase collects explicit ballots; the deliverable is the **auditable process artifact** (weighted tally, raw ballots, losing-side dissents), not epistemics — LLM ballots are correlated and the plan says so out loud. + +- **Model:** `PhaseType.VOTE` (the new enum value flushed every exhaustive switch at compile time — `mapPhaseToEntryType` now maps to F4's existing `TranscriptEntryType.VOTE`, so commit-reveal peer-hiding worked before any new code ran); `VoteConfig` (MAJORITY|APPROVAL, EXPLICIT|LAST_SYNTHESIS options, quorum, per-agent weights, `weightByConfidence` — default off with the correlated-self-report caveat in its Javadoc — and `tiePolicy`) as a 12th `DiscussionPhase` component with the usual compat constructor. +- **Independence is enforced, not advised:** `AgentGroupStore.validateVotePhases` HARD-rejects a VOTE phase that is not PARALLEL + `ContextScope.NONE` (plus: `targetEachPeer`, EXPLICIT with <2 options, negative weights). `HUMAN_DECIDES` is **save-time rejected until I6 ships human members** — the plan sequences I14 before I6, so shipping a silently-degrading enum value would be a lie; the queue's I6 item wires it. Deviation recorded here. +- **`VoteTallyEngine`:** three-tier ballot parse mirroring `DebateVerdictParser` (strict JSON with `FAIL_ON_TRAILING_TOKENS` → embedded JSON → exactly-one-option text scan; out-of-contract votes are non-ballots, never write-ins), `Option A:` line extraction from the newest synthesis, weighted tally, quorum with abstentions/garbage counting against the denominator, dissents from losing statements. +- **Wiring:** the discussion loop tallies on the VOTE phase's last repeat; `PhaseExecutionEngine.recordVoteDecision` applies the tie policy — `MODERATOR_DECIDES` runs one moderator turn under `__vote_tiebreak` (the judge's separate-conversation-key rule: a "reply with ONLY the option" prompt must not become the moderator's recent history), resolved by the same exact-scan rule as a ballot. +- **`decision_reached` finally fires (the §4 gap, folded in as planned):** `fireDecisionReached` runs for vote decisions AND for I3 debate verdicts — after the dissent round, so the event's record carries the merged dissents. Slack renders a bounded tally block for VOTE records (instanceof-guarded — the tally map crossed serialization). + +**Tests (121 across the touched classes green; full `engine.internal` suite green; checkstyle clean):** parse tiers incl. ambiguous-two-options and out-of-contract refusals; label voting ("Option B" → positional); LAST_SYNTHESIS extraction (newest synthesis, colon and dash forms); weighted majority; exact tie → unresolved (never a winner by list position); confidence weighting on/off flips a tie; quorum arithmetic pinned to the "2 of 5" message; dissents + raw-ballot audit; tiebreak choice resolution; save-time validation matrix (PARALLEL/NONE/options/weights/HUMAN_DECIDES); service-level E2E: majority vote records the decision and fires `decision_reached` with the winner; tie + MODERATOR_DECIDES resolves via one tiebreak turn with method `vote+moderator-tiebreak`; tie + NO_DECISION records an honest NONE and the discussion does not fail; ballots land as VOTE entries. Slack tally block + malformed-tally no-throw. + +**Files:** `AgentGroupConfiguration` (VOTE + VoteConfig/VoteMethod/OptionsSource/TiePolicy), `AgentGroupStore`, `DiscussionStylePresets` (TEMPLATE_VOTE), `GroupContextBuilder` (VOTE branch + entry-type mapping), `VoteTallyEngine` (new), `PhaseExecutionEngine` (recordVoteDecision + fireDecisionReached), `GroupConversationService` (loop wiring), `SlackGroupDiscussionListener` (tally block), `docs/group-conversations.md`, 4 test classes. + +--- + +## 🔎 fix(groups): I6 PR #640 review round 1 (2026-08-08) + +**Repo:** EDDI (`feat/group-i6-human-members`) + +CI failure + all 20 review comments (CodeQL ×5, code-quality ×4, CodeRabbit ×11) triaged; every one accepted and fixed: + +- **CI**: `submit_group_human_input` added to `McpToolFilter`'s whitelist (a non-whitelisted MCP tool is unreachable dead code — the guard test caught exactly that). +- **The pending member can now READ their turn**: new `HitlAccessGuard.requireGroupConversationReadAccess` — owner/admin/approver PLUS the human member a pending turn waits on — used by the REST and MCP approval-status endpoints (whose summary now carries `pendingMemberId`/`pendingHumanPrompt` on both surfaces). The full-transcript view stays role-gated: the member's working material is the rendered prompt, never the transcript. +- **Mid-phase resume no longer replays earlier repeats**: the phase loop starts at the bookmark's `repeatIdx` (clamped) — each replayed repeat was a full round of duplicate turns and spend. +- **Metric/audit/resume-event moved AFTER the successful executor submit** in the human-turn resolution (a rolled-back attempt must not pollute the resume metric or the EU-AI-Act trail — the rule `resumeDiscussion` already followed); the rollback path now re-checks the control token (`removeTokenAndConvertIfSignalled`) so a cancel racing the rollback is not dropped; and the method returns a **freshly-read copy** instead of the live instance the background leg mutates under the serializer. +- **Slack listener releases its completion latch on a human pause** (it blocked `awaitCompletion`'s full 300s on every human turn); **deletion of an `AWAITING_HUMAN_INPUT` conversation runs the paused-cleanup branch** (timeout schedule + ephemeral agents + signing cursor); **the signing cursor now survives a human pause** in `executeDiscussion`'s finally; **crash-recovery sweeps are isolated** (a failing approval query no longer skips the human re-arm). +- **Inbox starvation fixed**: both pause states are queried with the full limit, merged oldest-pause-first, then capped — approvals can no longer push a member's own turn out of the window. +- **Validation**: `turnTimeout` must be positive (PT0S/PT-4H parsed but armed an immediately-firing timeout that silently skipped every turn); `"members": null` cannot NPE the nested/moderator checks. +- **F2 drift guard explicitly scoped to approval bookmarks** (human bookmarks never reach `resumeDiscussion` — disjoint states — and their advanced `speakerIdx+1` semantics would false-positive at the last-speaker boundary; the executors clamp instead). +- CodeQL ×5 sanitized; the `HumanTurnRequired` `@param` docs moved from class to constructor Javadoc (×4). + +**Tests:** +7 (read-access matrix incl. stranger-refused + wrong-group-404; full-view refusal for the pending member; PT0S/PT-4H rejection; null-members no-NPE; MCP guard/gate re-alignment ×2). Suites: 2882 green across `engine.internal` + `configs.groups` + `engine.hitl` + `engine.mcp`; checkstyle clean. + +--- + +## 🙋 feat(groups): I6 — humans as group members (2026-08-08) + +**Repo:** EDDI (`feat/group-i6-human-members`) + +Fourth Wave 2 queue item. Humans can finally *speak*, not just gate: a `MemberType.HUMAN` member's turn pauses the discussion in a **new state `AWAITING_HUMAN_INPUT`** until they submit — deliberately not `AWAITING_APPROVAL` (approval endpoints must never accept free text; inboxes must tell "approve/reject" from "you're up"). + +- **Turn flow, exactly per the plan:** the phase loops intercept HUMAN speakers before any LLM machinery, render their input *exactly like an agent's* (`buildPhaseInput`), and surface a `HumanTurnRequired` control-flow signal; `executeDiscussion` catches it, and `GroupHitlCoordinator.commitHumanTurnPause` persists `PendingHumanInput{memberId, displayName, phaseIdx, repeatIdx, speakerIdx, entryType, renderedPrompt, onTimeout, requestedAt}` + the F2 `ResumePoint` — writer-less until now, this is its first producer. The human's turn is counted at the pause (`pausedTurnCount = turns+1`), so it is never free. +- **Submission:** `POST /groups/{groupId}/conversations/{id}/human-input` + MCP `submit_group_human_input`. Authorization is a NEW guard (`requireGroupHumanInputAccess`): the pending member's own principal or admin — deliberately narrower than approve (an `eddi-approver` may decide approvals; speaking as another human is impersonation). The answer lands as the phase's natural entry type (captured at pause time so config edits can't re-type it), the bookmark advances past the answered speaker, the CAS out of `AWAITING_HUMAN_INPUT` makes double-submits a 409, and the discussion re-enters like an approval resume. Drift-checks run BEFORE any mutation — a stale bookmark refuses the submission instead of needing rollback; the one post-CAS failure (executor saturation) rolls the append back and restores the pause. +- **Timeouts:** `humanMemberConfig {turnTimeout (ISO-8601, null=wait), onTimeout=SKIP_TURN|ABORT}`, riding the HITL schedule machinery with a new surface `group-human` — the SKIP_TURN/ABORT policies are NOT `HitlTimeoutPolicy` values, so the fire handler branches on surface before parsing. SKIP_TURN writes the plan's SKIPPED entry ("no response from within ") and resumes; ABORT cancels gracefully. Crash recovery re-arms human-turn timeouts (policy bookmarked on the pending record). +- **PARALLEL phases:** humans never join the fan-out; agents run first, humans are then prompted sequentially against the **pre-fan-out snapshot** (blindness preserved). The one carve-out from "PARALLEL never honors a bookmark": a `HUMAN_TURN_PARALLEL` resume skips the fan-out entirely and resumes the human tail — no duplicate agent turns on resume. +- **Save-time matrix** (`AgentGroupStore.validateHumanMembers`, hard-throws — safe because no legacy doc can contain the new enum value): displayName required; no humans in task-force (PLAN/EXECUTE/VERIFY) or `targetEachPeer` groups (preset-EXPANDED, or the check is inert); nested groups containing humans rejected one level deep (runtime backstop in `MemberTurnExecutor` cancels a stranded `AWAITING_HUMAN_INPUT` child); `turnTimeout` must parse. Human moderator allowed + warned — and `resolveParticipants` now preserves the roster's member for the moderator id (the 4-arg ctor silently DEMOTED a human moderator to an agent). +- **Surfaces:** `human_input_requested` event (constant + record + listener default + SSE forward incl. OpenAPI list + Slack "you're up" notice, mrkdwn-escaped); pending human turns join the existing inbox as `pauseType: "HUMAN_TURN"` + `pendingMemberId` (no third inbox) and the member sees their own turns without owning the conversation; `availableActions` gains `submitHumanInput`; MCP `get_group_approval_status` reports the pending member and their rendered prompt; cancel paths (`cancelDiscussion`, pause→cancel conversion, `removeTokenAndConvertIfSignalled`) all treat the new state as a first-class pause. +- **Defense in depth:** a HUMAN member reaching `executeAgentTurn` (convergence judge, dissent round, task-force wave, nested group — contexts that cannot pause) yields a SKIPPED entry, mirroring the member-HITL SKIP precedent. +- **Deviation, recorded:** the I14 `HUMAN_DECIDES` tie-policy wiring stays save-time-rejected — I14 (PR #638) is not merged; wiring it is a small follow-up once both branches land (I12 needs both anyway). + +**Tests (+23 across 8 classes; `engine.internal` + `configs.groups` + `engine.hitl` suites 1869 green; checkstyle clean):** sequential pause with rendered prompt + absolute index + budget-before-human ordering; parallel fan-out-then-human with pre-fan-out blindness (captor on the prompt transcript), resume-tail without fan-out re-run, and post-resume blindness; commitHumanTurnPause bookmark/pending/schedule shape (surface + policy asserted); submit→record→advance→CAS→re-enter (captured runnable proves the re-entry coords); wrong member / wrong state / blank / oversize / config drift all refuse BEFORE mutation; SKIP_TURN timeout writes the named SKIPPED entry and advances; cancel-of-human-pause clears the pending turn; timeout-handler routing (SKIP_TURN/ABORT/unknown-degrades); guard matrix (member ok, admin ok, owner+approver FORBIDDEN, wrong-group 404, auth-off no-op, inbox shows the member their turn); save-time matrix; human-moderator preservation; defense-in-depth skip; 4 enum pins updated (the I14/I8 CI lesson — caught locally this time). +## 🔎 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. +## 🔎 fix(groups): I18 PR #642 review round 1 (2026-08-08) + +**Repo:** EDDI (`feat/group-i18-bidding`) + +All 6 CodeQL log-injection findings in `TaskForceEngine`'s bid round accepted and fixed: `LogSanitizer` applied to every caller-influenced value in the bid-round logs (groupId, task subject, bidder/winner agentId, exception messages) — 7 sites sanitized, the 6 flagged plus the bid-turn-failure log the scan will otherwise flag next round. Bid suites (51) green. + +--- + +## 🏷️ feat(groups): I18 — bid-based task assignment, CNP-lite (2026-08-08) + +**Repo:** EDDI (`feat/group-i18-bidding`) + +Second Wave 3 queue item (adopted from the research review, scoped down — the turn-auction extension stays REJECTED: an extra call per member per turn to decide who talks doubles cost to save cost). The planner cannot know members' actual fit or load; the Contract Net Protocol's announce-bid-award loop maps onto the existing wave scheduler. + +- **`assignmentMode = ROLE (default) | BID`** on `TaskDefinition` (per task) and `GroupTaskConfig` (group default), both with compat constructors — every pre-I18 config resolves to ROLE through `TaskBidEngine.effectiveMode`'s task → group → ROLE chain. +- **PLAN leaves BID-mode tasks unassigned** (both the pre-configured and LLM-planned paths) — assigning there would preempt the auction with the planner's guess. +- **The wave's bid round** (`TaskForceEngine.runBidRoundIfNeeded`, before each wave's grouping so awards join the same wave): eligible members (non-moderator AGENTs) each get one **blind, parallel** bid turn — the prompt carries the announced batch and NOTHING else (no transcript, no peer bids; blindness is what makes the self-assessed confidences comparable, and the honesty rule is stated to the model: an inflated confidence wins you work you will fail at, on the record). Replies land as `BID` transcript entries — F4's blind-bid visibility (peer-hidden while the phase runs) has its first producer. +- **Deterministic award, never a stalled wave**: highest confidence per task; ties break by speaking order then agent id (identical on every pod); a task nobody bid on falls back to ROLE/round-robin; the auction skips itself with a LOG (a silent cap reads as coverage) when <2 bidders, <2 unassigned tasks, or the turn budget cannot cover one bid turn per member. Bid turns count toward the turn budget and their cost flows through the normal member-turn attribution. +- **The award is per-task metadata** (`SharedTaskList.awardedBids[taskId] = AwardedBid{agentId, confidence, estimatedComplexity, rationale}`), deliberately NOT a global DecisionRecord — an award is a scheduling fact about one task, not the discussion's conclusion. +- Parse discipline mirrors `VoteTallyEngine`: three tiers, FAIL_ON_TRAILING_TOKENS, unknown subjects dropped (an out-of-contract bid is never guessed onto a task), confidence clamped to [0,1], first-bid-per-task within one reply. + +**Tests (13 new: 8 `TaskBidEngineTest` + 5 `TaskForceEngineTest`; `engine.internal` + `configs.groups` suites 1694 green; checkstyle clean):** parse tiers + clamping + unknown-subject drop; award to highest confidence; tie-break determinism (speaking order, then agent id); no-bids absence; effective-mode chain; worthwhile-auction caps; blind prompt content; engine-level award with recorded bid + turn accounting + BID entries; **blindness asserted on the captured prompts** (no peer rationale/confidence leaks); no-bids ROLE fallback never stalls; skip-conditions make zero LLM calls; ROLE-mode tasks never auctioned. +## 🔎 fix(groups): I13 PR #644 review round 1 (2026-08-08) + +**Repo:** EDDI (`feat/group-i13-standing-teams`) + +All 3 findings (CodeQL ×2, code-quality ×1) accepted and fixed: the workspace-deletion and cadence-deletion logs sanitize their caller-influenced values; `GroupWorkspace.getCadences` returns an unmodifiable view with mutation through new `addCadence`/`removeCadence` (the NegotiationState treatment) — REST layer and tests rewired. Suites (73) green. + +--- + +## 🏭 feat(groups): I13 — standing teams (2026-08-08) + +**Repo:** EDDI (`feat/group-i13-standing-teams` — stacked on the I8 branch: retro lessons flow through I8 unchanged) + +Ninth queue item, the Wave 3 flagship. A group conversation is an episode; the **`GroupWorkspace`** is what persists — backlog, cadences, metrics. Deliberately thin glue over existing machinery: the backlog IS a `SharedTaskList`, scheduling IS `SchedulePollerService`, the run ceiling IS I1's inherited-ceiling slot. + +- **Model + store:** `GroupWorkspace` (own collection, one doc per group id): `backlog` (SharedTaskList reused whole so pulled tasks flow straight into the task-force machinery), `metrics {discussions, tasksVerified, totalCost, lastRunAt, perMemberStats}` (per-member stats are reliability RECORDING only — the research-adopted substrate; no routing/weighting in v1), `cadences [{cadenceId, scheduleRef, inputTemplate (Qute), maxBacklogTasksPerRun=5, maxCostPerRun, createdBy}]`, `runningDiscussionId` + `pulledTaskIds`. `IGroupWorkspaceStore`/`GroupWorkspaceStore` follow the GroupConversationStore single-version pattern; `casRunningDiscussion` is a conditional store write (`storeIfFieldEquals`) — cluster-safe, no in-JVM locks, idle sentinel `""` because the CAS compares a concrete stored value. +- **`TeamCadenceService`** (the DreamService pattern, exactly): metadata contract `teamCadenceType="team_cadence"` + a dedicated `ScheduleFireExecutor.fireTeamCadence` branch, so cluster claim/lease/retry/dead-letter/fire-logs come free. The fire protocol is crash-proof by construction: **reconcile** (a finished previous run is written back FIRST; one still running — possibly paused at an HITL gate for days — skips the fire) → **pull** (top-N executable by priority; empty pull skips, logged) → **claim** (CAS; a lost race cancels the just-started discussion and stands down) → **run**. Deliberate skips are COMPLETED fires with the reason logged; real failures are FAILED so they retry and dead-letter. +- **Task injection without config writes:** new `GroupConversationService.startCadenceDiscussionAsync` — the pulled tasks replace `config.tasks` on the call's own fresh read (a runtime copy; the stored config is never written) and `maxCostPerRun` rides `inheritedCostCeiling`, so `effectiveCostCeiling` takes the tighter of it and the group's own ceiling (dollar-primary, the Dream precedent). The discussion runs under the cadence **creator's** identity (`createdBy`), not a synthetic scheduler user. +- **Writeback** at the next fire or on workspace read (read-repair in the REST layer) — never from the discussion thread, so a crash loses nothing: VERIFIED stays VERIFIED on the backlog + credits the assignee's stats; anything else returns to PENDING with the reviewer feedback appended to the description (the cross-run retry loop); FAILED/CANCELLED returns every pulled task untouched; a vanished discussion releases the claim instead of stalling every future fire. +- **Surfaces:** REST `/groupstore/groups/{groupId}/workspace` (GET workspace/backlog with read-repair; POST backlog — cap 200 with an actionable 409; POST/DELETE cadences — the cron is validated at creation by computing the first fire, and the schedule carries the dispatch metadata); MCP `add_team_task` + `list_team_backlog` (whitelisted; filter pins green). **Teardown:** a permanent group deletion cascades to the workspace; a soft delete keeps it (the group can come back). + +**Tests (+14 TeamCadenceServiceTest, +9 RestGroupWorkspaceTest, +2 fire-executor dispatch, +4 MCP, +2 cascade):** fire pulls top-priority tasks and the captured `startCadenceDiscussionAsync` call carries them, the creator identity and the dollar ceiling; every skip (empty backlog, still-running, lost claim → cancel); reconcile-then-run; writeback matrix (VERIFIED credited / failed-with-feedback returns as the retry loop / FAILED returns untouched / vanished releases) — the feedback-appended and VERIFIED assertions are the mutation-check; backlog cap actionable on both surfaces; cadence schedule metadata + invalid-cron-fails-at-creation; permanent-delete cascade vs soft-delete keep. + +--- + +## 🔎 fix(groups): I8 PR #639 CI round 1 + plan-mandated test extension (2026-08-08) + +**Repo:** EDDI (`feat/group-i8-retro-memory`) + +Follow-ups on the open PR (commits `8025962c6`, `c2b4b7a79`, `3de3de69e`): + +- **CI failures**: `AgentGroupConfigurationTest.phaseType_allValues` pins the enum size (RETRO is this branch's 12th value — same pin fixed for VOTE on the I14 branch), and `PostgresUserMemoryStoreUnitTest.getVisibleEntries_withGroupIds_includesGroupClause` pinned the pre-I8 bind order — now asserts all nine parameters incl. the derived `group:` owners. The third failing check (ClusterFuzzLite) was a transient gcr.io 403 pulling the fuzz image — it passed on the sibling PRs minutes later. +- **CodeQL**: the four flagged RetroEngine log sinks sanitized (conversation id, phase name, team owner, exception message). +- **Plan-mandated tests** the first commit missed (`UserMemoryToolScopingTest` extension, named explicitly in the I8 plan item): eviction can never delete a team-owned lesson even with the store wall deliberately breached (the fixed `"retro"` sourceAgentId is a second independent wall), and personal `visibility:self` entries never cross users through a shared group. + +--- + +## 🧠 feat(groups): I8 — retro phases harvest team-owned group memory (2026-08-08) + +**Repo:** EDDI (`feat/group-i8-retro-memory`) + +Third Wave 2 queue item, and the substrate I13 (Standing Teams) builds on. Discussions stop evaporating: a `RETRO` phase's lessons persist and surface as `{properties.*}` in every member's later discussions. + +- **`PhaseType.RETRO`** + built-in template (full-transcript review, JSON lessons contract) + `RetroConfig {maxLessonsPerRun=3, maxStoredLessons=50}`. The template *quotes* the default per-run cap; `RetroEngine` *enforces* the configured one at parse time regardless — the context builder deliberately does not receive the group config. +- **Storage exactly as the plan resolved (V2):** `IUserMemoryStore.upsert` under the synthetic team owner `"group:"+groupId` with `group` visibility. New `TEAM_OWNER_PREFIX` contract constant; lessons belong to the team, not the human who ran the discussion, and survive that human's GDPR erasure without carrying their identity. +- **The additive synthetic-team-owner recall branch, on BOTH backends:** Mongo's `buildVisibilityFilter` and Postgres's `buildVisibilityQuery` (+ its bind order) now OR in a narrow team scope — owner ids DERIVED from the supplied group ids (never caller-supplied), `group` visibility, group-id overlap. The user's own scope is untouched, per the plan's "do not widen the existing user-scoped group branch". Both made package-private static so the filter/SQL shapes are directly assertable — and both are pinned side by side so the backends cannot drift. +- **Idempotency + bounded growth:** key `retro:` with a FIXED `sourceAgentId` ("retro") — the upsert identity for group entries is `(userId, key, sourceAgentId)`, so a real speaker id would have made the same lesson from two speakers two rows. FIFO eviction past `maxStoredLessons` via the newest-first recall (everything past the cap is the oldest); `RetroEngine` is the ONLY reaper — `UserMemoryTool`'s eviction only ever touches the calling agent's `self` entries. +- **Wiring:** discussion-loop harvest on the RETRO phase's last repeat, before the persist (a crash must not lose lessons a stored document claims were taken); `IUserMemoryStore` reaches the facade by the established null-safe field-injection pattern. New `retro_recorded` event (constant + record + listener default + SSE forward) — fires even at zero lessons stored, which is itself signal. + +**Tests (11 new, all green; `engine.internal` + `configs.properties` suites 1572 green; checkstyle clean):** parse tiers (strict/fenced/prose-refused); per-run cap enforced past what the model was told; idempotent team-owned upsert against a REAL in-memory store with the production upsert identity (a mocked idempotency claim would prove nothing); FIFO evicts the oldest and never the newest; `retro_recorded` carries the stored count; null store and failing store never fail the discussion; Mongo filter shape (no-groups → user scope only; with-groups → derived `group:` owners paired with group visibility; no foreign human id reachable); Postgres SQL shape incl. the `??|` escape arithmetic pinning the bind order. + +**Files:** `AgentGroupConfiguration` (RETRO + RetroConfig), `DiscussionStylePresets` (TEMPLATE_RETRO), `GroupContextBuilder` (RETRO branch + entry mapping), `RetroEngine` (new), `GroupConversationService` (wiring + store injection), `IUserMemoryStore` (TEAM_OWNER_PREFIX + contract Javadoc), `MongoUserMemoryStore`, `PostgresUserMemoryStore`, `GroupConversationEventSink`/listener/SSE, `docs/group-conversations.md`, 3 test classes. +## 🔎 fix(groups): I17 PR #637 review round 2 (2026-08-08) + +**Repo:** EDDI (`feat/group-i17-shared-artifacts`) + +Two comments triaged: + +- **Announce mutex no longer held across listener callbacks (CodeRabbit Major, accepted):** `announceArtifactChanges` held `artifactAnnounceMutex` through `onArtifactUpdated`, so one slow/backpressured SSE client blocked every other turn's end-of-turn drain. Now the mutex guards only the HANDOFF: exactly one thread at a time is the publisher — it drains under the mutex, releases it, fires the callbacks, and loops for late arrivals; every other thread sees the publisher flag and leaves, its changes guaranteed to ride the publisher's next pass. Write order preserved (single announcer, FIFO queue), no caller ever blocks on a listener. New test drives a write + reentrant announce from INSIDE a callback: published exactly once, in order, nothing stranded, no deadlock. +- **CodeQL log-injection (stale):** raised against the initial commit 6aeba1393; the flagged attach-artifacts log was sanitized in round 1 (74c0acaf7). Reply-only. + +`MemberTurnExecutorTest` (14) + artifact suites green. + +--- + +## 🔎 fix(groups): I17 PR #637 review round 1 (2026-08-08) + +**Repo:** EDDI (`feat/group-i17-shared-artifacts`) + +All 11 review comments (CodeRabbit ×9, Copilot/CodeQL ×2) triaged; every one accepted and fixed: + +- **Meta-schema validation at save time** (`ArtifactValidators.schemaSpecProblem`): `getSchema(spec)` only parses — `{"type":"strng"}` passed and misbehaved at write time. Specs now also validate *as instances* against the bundled 2020-12 meta-schema (no network I/O; degrades to parse-only with a WARN if the bundled resource can't load, rather than rejecting every config). +- **ReDoS bound on REGEX validators**: config-authored pattern × 256 KB LLM content could backtrack catastrophically and pin the member turn. `checkRegex` now matches through a deadline-guarded `CharSequence` (500 ms, sampled every 1024 char accesses) and refuses the write on expiry — fails closed, like every other broken-spec path. +- **`[null]` validator entries**: `List.copyOf` NPE'd during config deserialization, preempting `requireValidSpecs`' positional message; now an unmodifiable null-tolerant copy. +- **Artifact event ordering + late writes**: drain+announce now holds a per-conversation mutex (two PARALLEL turns ending together could split the queue and publish v2 before v1), and `executeDiscussion`'s `finally` runs one **final announce pass per leg** so a write accepted by a timed-out member's still-running agent is announced instead of stranded. A write after even that pass keeps the artifact — only its live event is best-effort, by design. +- **`listByGroupConversationId` order**: both backends sort DESC; the interface promises oldest-first. Now re-sorted in Java per the contract. +- **`deleteByGroupConversationId`**: same processed-set/no-progress guard as `deleteAllForUser` — an undislodgeable row is counted once and ends the loop instead of spinning `MAX_ERASURE_PASSES` times and inflating the count. +- **Slack mrkdwn injection**: artifact name/editor id are LLM-authored; `` in a name rendered as a real broadcast. Both fields now `&`/`<`/`>`-escaped. +- **Oversize refusal rounds up** (`Math.ceilDiv`): MAX+1 bytes no longer reads "256 KB is over the 256 KB limit". +- **GDPR cascade Javadoc** now names the shared-artifact step; **CodeQL log injection** at `populateArtifacts` sanitized. + +**Tests:** +7 (meta-schema reject, null-entry positional message, catastrophic-regex deadline, late-write announce pass, single-pass write order, oldest-first sort, no-spin cascade delete). Touched suites 1961 tests — green except the 27 known environmental socket-bound errors (SafeHttpClient/SlackWebApi/Weather/WebScraper), which fail identically on an untouched tree. + +--- + +## 📄 feat(groups): I17 — shared artifacts (blackboard-lite) (2026-08-08) + +**Repo:** EDDI (`feat/group-i17-shared-artifacts`) + +First Wave 2 queue item from `planning/group-collaboration-NEXT.md` §3. Agents can now **co-edit typed documents** instead of only talking: four member tools — `createArtifact`, `readArtifact`, `proposeArtifactUpdate`, `listArtifacts` — gated by a new `artifactConfig` on the group config. + +**Design decisions, per the plan (and the plan's own rejections honored):** + +- **Own collection, never embedded.** `SharedArtifact` + `ISharedArtifactStore`/`SharedArtifactStore` follow `GroupConversationStore`'s single-version runtime-document pattern. The discussion loop's whole-document stale-snapshot persists cannot clobber artifact writes, which is also why — unlike I5's task tools — the artifact tools write **through the store directly**. The live registry is still consulted: membership at assembly (`getForMember`, the caller-supplied-id IDOR guard), liveness at write time, and accepted writes ride a new transient change queue on the live `GroupConversation`. +- **Deterministic CAS-and-retry, explicitly not an LLM fusion arbiter.** The version CAS needed a storage primitive that doesn't exist for numbers: `storeIfFieldEquals(String)` text-compares, which "works" on Postgres (`data->>` renders JSON numbers as text) and **silently never matches on Mongo** (typed BSON equality). New `storeIfFieldEquals(…, long)` overload on `IResourceStorage` + both backends, same no-silent-degrade contract (the default throws). Stale writers get the plan's sentence: *"artifact changed since you read it (now vN); re-read and merge your change."* +- **Declarative validators only.** `JSON_SCHEMA` (new dependency `com.networknt:json-schema-validator` — the victools libraries only *generate* schemas), `REGEX`, `MAX_LENGTH`. Specs hard-fail the config save (`ArtifactValidators.requireValidSpecs` from `AgentGroupStore`, `HitlConfigValidation`'s contract); write-time failures are rejection sentences and the gate fails closed on a broken spec. Content ≤ 256 KB. +- **Events without a listener reference:** tools can't fire SSE/Slack events (`ToolAssemblyContext` carries no listener — the structural gap that left I5's planned `task_added_by_agent` unfired). Accepted writes queue an `ArtifactChange` on the live instance; `MemberTurnExecutor` drains the queue in a `finally` after every turn and fires the new `artifact_updated` event (sink constant + record + SSE forward + Slack line + OpenAPI description lists). Drained even with a null listener so the queue cannot grow unbounded. +- **Lifecycle:** artifacts are attached to the discussion status payload at read time in the service (so REST *and* MCP `read_group_conversation` carry them — `availableActions` idiom, `READ_ONLY`, never trusted back from storage); close/delete cascade removes them (`GroupLifecycleOps`, warn-and-continue so a broken artifact store can't make discussions undeletable); GDPR erasure sweeps them **user-keyed** via a stamped `ownerUserId` (page/exact-recheck/fail-loud contract copied from the group store) as a new `GdprComplianceService` cascade step. +- **Caps:** `maxArtifactsPerDiscussion` (default 5) counted inside a `synchronized (liveInstance)` block — creation is check-then-act and PARALLEL phases genuinely race; updates need no lock, the CAS decides. + +**Tests (148 across 8 classes, all green):** tools against a real in-memory CAS store (stale-version retry sentence with the CURRENT version, concurrent same-version writers → exactly one winner, FINAL freeze, foreign-discussion ids don't resolve, validator chain, refusals leave no side effect); provider gate matrix (every uncertainty → contribute nothing, membership not existence, `enableBuiltInTools` still applies); store CAS through the numeric overload with `verify(never()).store(…)`; anchored+escaped filters with Java exact-recheck; erasure paging/fail-loud; lifecycle cascade ordering (`inOrder` artifact-delete before document-delete) + cascade-failure-still-deletes; GDPR step + not-resolvable skip + failure-continues; turn-executor drain (exactly once, null-listener drain); Slack lines incl. degenerate-payload skip. **Mutation notes:** degrading the store CAS to an unconditional store does not even compile (the gone-document catch becomes unreachable) — the CAS call is structurally load-bearing; the Mockito-verified negatives (`never().store`, `specs().isEmpty()`) pin the rest. + +**Files:** `SharedArtifact`, `ISharedArtifactStore`, `SharedArtifactStore`, `ArtifactValidators`, `ArtifactTools`, `ArtifactToolsProvider` (+ `AgentOrchestrator` phase-1 wiring), `AgentGroupConfiguration` (`ArtifactConfig`/`ArtifactValidator`/`ValidatorKind`), `GroupConversation` (change queue + read-time `artifacts`), `IResourceStorage` + Mongo/Postgres (numeric CAS), `GroupConversationEventSink`/listener/SSE/Slack, `GroupLifecycleOps`, `GroupConversationService`, `GdprComplianceService`, `AgentGroupStore`, `pom.xml`, `docs/group-conversations.md`, 8 test classes. +## 🔍 fix(review): PR #636 review findings — NaN cost guard, ceiling-gated summarizer, visible-entry boundary (2026-08-08) + +**Repo:** EDDI (`fix/group-pre-feature-defects`, PR [#636](https://github.com/labsai/EDDI/pull/636)) + +Every reviewer finding on #636 triaged; the real ones fixed, each with a pinning test: + +- **NaN poisons the cost ledger (CodeRabbit, real).** Both `GroupCostLedger.recordSystemCost` and `LlmTask.accumulateCost` guarded with `delta <= 0.0` — NaN fails *every* comparison, so it slipped through, made `totalCost` NaN, and every ceiling comparison against NaN is false: the ceiling silently never fires again. Now `!Double.isFinite(x) || !(x > 0.0)`. Tests: NaN/∞/non-positive rejected in both accumulators. +- **Summarizer spend must be ceiling-gated (Copilot, real).** The I9 boundary call now runs behind `GroupCostLedger.wouldExceedCeiling`, exactly like the convergence judge and the dissent round. The pinning test needed care — the first attempt was vacuous because the per-turn gate fired before any boundary could (mutation survived); rebuilt so the first phase completes under the gate while blowing the ceiling cumulatively. **Mutation-checked: removing the guard fails exactly this test.** +- **Summary boundary counts raw entries, not visible ones (Copilot, real).** Bookkeeping rows (SKIPPED/CONVERGENCE/…) in the tail shrank the verbatim window below `maxRecentEntries` while newer real contributions got summarized away. New `summaryBoundary` walks back over `isSummarizable` entries (one shared predicate with `renderForSummarizer`). Test: bookkeeping interleaved in the tail keeps the 3 newest *visible* entries verbatim. +- **Blank summarizer identifiers (CodeRabbit, real-minor).** Whitespace-only `llmProvider`/`llmModel` bypassed the null checks and reached `SummarizationService`. Normalized to null in `ContextWindowConfig`'s compact constructor (the one choke point); the store warn simplifies to null checks. Test: blank identifiers → truncation fallback, `verifyNoInteractions(summarizer)`. +- **CodeQL log injection ×4 (real).** `gc.getId()` and the group name are caller-influenced; the three windowing WARNs and the save-time warn now go through `LogSanitizer.sanitize`. +- **Live-transcript iteration (CodeRabbit, partly right).** The `updateWindowSummary` copy already held the correct monitor (`Collections.synchronizedList`'s mutex IS the wrapper object — the PhaseExecutionEngine comment documents this), but the windowed `filterByScope`'s indexed walk over the LIVE list could interleave with tool-thread appends (recruitment's FACILITATION entries). It now copies under the wrapper monitor first. +- **`memberCosts` key shape (CodeRabbit, documentation).** The "agentId → cost" Javadoc was stale *before* the nested-cost fix — I2's `__convergence_judge` and I4's `__dissent__*` conversation keys already live in that map. Rewritten to the actual invariant: one key = one conversation, `totalCost` = sum of everything. Copying a member total onto `memberCosts[agentId]` (the suggested fix) would double-count in the re-sum. + +144 tests across the touched classes green; checkstyle clean. + +--- + +## 🪟 feat(groups): N2/I9 — transcript windowing for rendered member context (2026-08-07) + +**Repo:** EDDI (`fix/group-pre-feature-defects`) + +Third and last pre-feature item from `planning/group-collaboration-NEXT.md` §2 — a live cost bug, not a new collaboration mode: FULL-scope phases re-fed the entire transcript to every member every turn (~quadratic prompt cost), and every queued Wave 2/3 item makes transcripts longer. Landed in `GroupContextBuilder` per the plan (`### I9`, plan line ~337). + +- **Config:** `AgentGroupConfiguration.ContextWindowConfig` (`contextWindow`) — `enabled=false`, `maxRecentEntries=30`, `summarizeOverflow=true`, plus `llmProvider`/`llmModel` for the summarizer and optional `inputPricePer1M`/`outputPricePer1M` (I1 attribution, reusing N1's shared `TokenPricing`). Boolean-not-boolean for `summarizeOverflow` so an omitted JSON key means true; compact-constructor normalization in the `GroupTaskConfig` style. Save-time warn in `AgentGroupStore` when summarization is on but no model is named. +- **Rendering:** windowed `filterByScope` overload — when the FULL/ANONYMOUS scope-filtered context exceeds the cap, older entries collapse into one leading "System" pseudo-entry: the rolling summary when it covers the omitted range, else the `[n earlier entries omitted]` truncation marker (the `summarizeOverflow=false` path and the failure fallback). The verbatim/summary split is the summary's **raw-transcript boundary**, so there is never a gap or duplication; between boundaries the tail may grow a few entries past the cap and the next boundary re-tightens. The stored transcript is never modified; signing verifies raw entries as before. +- **Summaries:** extended **incrementally at phase boundaries only** (`updateWindowSummary`, called from the discussion loop before each repeat — never per member turn), previous summary + new slice, mirroring `ConversationSummarizer`'s self-correcting algorithm via the shared `SummarizationService` (unification rule). Failure or empty answer leaves stored state untouched: WARN, truncation fallback, next boundary catches up with a larger batch. Summarizer spend lands on the discussion ledger via `GroupCostLedger.recordSystemCost` keyed `system:summarizer:{variant}:{boundary}` (idempotent per extension, distinct extensions sum). +- **One deliberate deviation from the plan text:** the plan named a single `transcriptSummary`/`summaryUpToIndex` pair *and* required ANONYMOUS summarizer input to use "Anonymous" labels. One shared summary cannot satisfy both — a FULL-built summary carries real names and would de-anonymize an ANONYMOUS phase through the back door. `GroupConversation` therefore carries a second, lazily-built pair (`anonymousTranscriptSummary`/`anonymousSummaryUpToIndex`); a group that never uses ANONYMOUS never pays for it. Fields are additive with correct defaults — no `CURRENT_SCHEMA_VERSION` bump (a legacy document simply starts summarizing at its next boundary). +- **Wiring:** `PhaseExecutionEngine`'s three `buildPhaseInput` call sites pass `config.getContextWindow()` + gc; `SummarizationService` reaches the facade by the established field-injection pattern (null in direct-construction unit tests → truncation fallback). The I2 convergence judge's input is untouched (already bounded to two rounds); SYNTHESIS deliberately keeps the full picture. + +**Tests** (`GroupContextBuilderWindowingTest`, 13): boundary at exactly the cap (must exceed, not meet); truncation marker; summary + boundary-split tail (no gap/duplication); ANONYMOUS uses the anonymous summary and labels (the named FULL summary must not surface); incremental extension (second call sees previous summary + only the new slice); failure/empty-answer state untouched + rendering falls back; priced summarization reaches `totalCost`; no summarizer call outside its remit (wrong phase type/scope, below cap, summarization off); config normalization. **Mutation-checked:** re-feeding the whole prefix instead of the new slice fails exactly the incremental test. `PhaseExecutionEngineTest`'s input stub updated to the new 9-arg overload (same reasoning as its own comment: a shorter stub silently nulls every input). Full `engine.internal` suite: 1438 green; checkstyle clean. + +**Files:** `AgentGroupConfiguration.java`, `GroupConversation.java`, `GroupContextBuilder.java`, `PhaseExecutionEngine.java`, `GroupConversationService.java`, `GroupCostLedger.java`, `AgentGroupStore.java`, `TokenPricing.java` (now public), `docs/group-conversations.md`, `planning/group-collaboration-NEXT.md` (all three §2 items marked done), tests. + +--- + +## 🪜 fix(schema): N3 — legacy documents now enter the migration ladder at the bottom (2026-08-07) + +**Repo:** EDDI (`fix/group-pre-feature-defects`) + +Second pre-feature defect from `planning/group-collaboration-NEXT.md` §2, filed by the round-5 review of #626. `GroupConversation.schemaVersion` was initialised to `CURRENT_SCHEMA_VERSION` (3). Every pre-F6 production document has no `schemaVersion` key, Jackson leaves the initialiser standing, so those documents loaded **claiming schema 3 while being version-1-shaped** — and `prepareForResume`'s ladder ran `for (v = 3; v < 3; ...)`: zero iterations on exactly the documents it exists for. Fixed now, while it is free: no released build has ever written a versioned document, so nothing in production carries a wrong claim to migrate away from. + +- **Test first, watched it fail:** `documentWithoutVersionKey_claimsLegacyVersionNotCurrent` deserialises `{}` and asserts version 1 — failed with `expected: <1> but was: <3>` before the fix, exactly the round-5 finding. This is the case none of the four previous review rounds' tests covered (they all *set* a version). +- **The fix is a split, not a re-initialisation:** new `LEGACY_SCHEMA_VERSION = 1` is the field initialiser (the version a key-less document claims — it never moves), and the single creation point (`GroupConversationService.createGroupConversation`) stamps `CURRENT_SCHEMA_VERSION` explicitly. The initialiser alone cannot distinguish "absent" from "current" — Jackson runs the no-arg constructor either way — so the stamp must live at creation. +- **`ConversationMemorySnapshot` got the identical split** even though its `CURRENT` is still 1 (correct only by coincidence — first bump to 2 would have silently re-created the group side's bug). Its stamp lives in `ConversationMemoryUtilities.getMemorySnapshot`, the one place snapshots are built from live memory. A pinning test asserts `LEGACY_SCHEMA_VERSION` stays 1 when `CURRENT` bumps. +- **(b) Stale Javadoc fixed:** `GroupConversationSchemaMigrations.MIGRATIONS` claimed `CURRENT_SCHEMA_VERSION` is 1 and "there is nothing yet to migrate from" — it is 3, and the identity-default path has already been exercised twice. Rewritten (plus the single-conversation mirror and the stale test-class Javadoc). + +**Mutation-checked:** removing the creation stamp fails exactly `discuss_stampsCurrentSchemaVersionOnTheCreatedDocument` (a fresh document would persist claiming legacy). Deserialisation side proven by the red→green cycle above. 133 tests across the six touched classes green. + +⚠️ **Surefire note for the next session:** `-Dtest=ClassName` prints `Tests run: 0` for the parent of `@Nested` tests while the nested groups report under their `@DisplayName` — read the run TOTAL, not the class line, before concluding nothing ran. + +**Files:** `GroupConversation.java`, `GroupConversationService.java`, `ConversationMemorySnapshot.java`, `ConversationMemoryUtilities.java`, both `*SchemaMigrations.java`, tests. + +--- + +## 🧮 fix(groups): nested-group cost is summed per child discussion, not overwritten (2026-08-07) + +**Repo:** EDDI (`fix/group-pre-feature-defects`) + +The N1 fold-in flagged in `planning/group-collaboration-NEXT.md` §4. `GroupCostLedger.accumulateNestedGroupCost` keyed `memberCosts` by the GROUP member's `agentId`, but every turn of a GROUP member spawns a **fresh child discussion** whose `totalCost` starts at 0 — and the map records by replacement (idempotency invariant: one key = one conversation's cumulative cost). So child N's total replaced child N−1's, only the last child's spend survived the re-sum, and the parent's ceiling checks ran against an undercount. + +Fix keeps the replacement invariant instead of breaking it with deltas: the attribution key is now `agentId:childConversationId` — one key per child conversation, replacement stays idempotent, multiple children of the same member sum. A child without an id falls back to the plain agentId (old behaviour). No production reader indexes `memberCosts` by agentId — the map is serialized whole. + +`MemberTurnExecutorTest.executeGroupMemberTurn_rollsUpChildDiscussionCost` pinned the old single-key shape and was updated to the new contract. New `GroupCostLedgerTest` case: two children ($1.00, $0.50) of one member total $1.50, not $0.50. **Mutation-checked:** reverting to the agentId key fails exactly that new test. + +**Files:** `GroupCostLedger.java`, `GroupCostLedgerTest.java`, `MemberTurnExecutorTest.java`. + +--- + +## 💰 fix(llm): N1 — price ordinary model calls so the ledger's common case is no longer $0 (2026-08-07) + +**Repo:** EDDI (`fix/group-pre-feature-defects`) + +First of the three pre-feature defects from `planning/group-collaboration-NEXT.md` §2. `AUDIT_COST` was written from `cascadeCostUsd + toolCostUsd` only, so a plain model call — no cascade, no priced tool — contributed **$0.00**: I1's `maxCostPerDiscussion` ceiling could never trip for an ordinary group, and `memberCosts`/`totalCost` were served over REST as if authoritative. + +- **`LlmConfiguration.Task` gains `inputPricePer1M`/`outputPricePer1M`** — same names and nullable semantics as the cascade fields (null = unpriced, contributes $0), so nothing changes for anyone not setting prices. Config-driven per Golden Rule 1: no hardcoded provider price table — it would be wrong within weeks. +- **The pricing arithmetic now lives once, in `TokenPricing.cost()`.** `CascadingModelExecutor.computeCost` (step→cascade price resolution) and `LlmTask.accumulateAuditEvidence` (task-level prices for plain calls) both delegate — the formula cannot drift between paths (§4.7 unification rule). +- **Precedence is explicit, not accidental:** `accumulateAuditEvidence` discriminates on the presence of the `cascadeCostUsd` metadata key, which the cascade branch always writes. A cascade run is priced by its steps alone; task-level prices apply only to non-cascade calls — cascade steps may target entirely different models, so inheriting the task price would price the wrong model. (This matches the precedence concern pre-filed in `docs/superpowers/specs/2026-07-21-manager-coverage-backend-design.md`.) Pinned by a test with deliberately absurd task prices on a cascade turn. +- **Validation:** negative task-level prices fail deployment (`CascadeConfigValidator`, same new-field hard-error rationale as cascade pricing; the validator now also runs its task-level block for cascade-less tasks). +- **`GroupCostLedger`'s "Known gap (V1)" Javadoc** rewritten — the gap is closed; `totalCost` remains a lower bound only for members whose configs carry no prices. + +**Tests** (`LlmTaskAuditLedgerTest`, `CascadeConfigValidatorTest`): plain priced legacy call accumulates the expected dollars; priced agent-mode turn sums token + tool cost; unpriced call still writes no cost key; cascade with absurd task prices set is priced by the cascade alone; negative task price throws at deploy. **Mutation-checked:** reverting the pricing line to `0.0` fails exactly the two new priced-plain-call tests and nothing else. + +The group-side chain (AUDIT_COST → `GroupCostLedger` → ceiling policies ABORT/SYNTHESIZE_NOW) was already pinned end-to-end by `GroupConversationServiceCostCeilingTest` with stubbed member cost; what could not exist before this fix — a plain call producing nonzero AUDIT_COST — is now the pinned link. + +**Files:** `LlmConfiguration.java`, `TokenPricing.java` (new), `CascadingModelExecutor.java`, `LlmTask.java`, `CascadeConfigValidator.java`, `GroupCostLedger.java`, `docs/langchain.md`, tests. + --- ## 🔀 merge: bring `origin/main` (PR #627 HITL request pinning) into the branch (2026-08-07) diff --git a/docs/group-conversations.md b/docs/group-conversations.md index 8f440db3bf..f7833d5d36 100644 --- a/docs/group-conversations.md +++ b/docs/group-conversations.md @@ -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) @@ -155,11 +156,386 @@ tools. a second writer racing it would corrupt the state machine that decides what runs next. Filing is the only agent-side write. + +## Transcript windowing + +A `FULL`- or `ANONYMOUS`-scope discussion phase re-feeds the transcript to every +member every turn, so a long discussion grows roughly quadratically in prompt +tokens. `contextWindow` bounds what each turn *renders* — the stored transcript +is never modified, and signing verification still runs on the raw entries: + +```json +"contextWindow": { + "enabled": true, + "maxRecentEntries": 30, + "summarizeOverflow": true, + "llmProvider": "openai", + "llmModel": "gpt-4o-mini", + "inputPricePer1M": 0.15, + "outputPricePer1M": 0.60 +} +``` + +When the scope-filtered context exceeds `maxRecentEntries`, the older entries +collapse into a single leading block: a rolling **summary** (extended +incrementally at phase boundaries via the shared summarization service — never +per member turn), or, with `summarizeOverflow: false`, a plain +`[n earlier entries omitted]` marker that costs no LLM call. A summarization +failure never blocks the discussion — the turn renders with the truncation +marker and the next boundary catches up. + +- Applies to `FULL` and `ANONYMOUS` scopes only. The `ANONYMOUS` variant keeps + its own summary, built from the same `"Anonymous"` labels the scope filter + produces — the summary can never de-anonymize a peer. +- `llmProvider`/`llmModel` name the summarizer; without them, overflow falls + back to the truncation marker (a save-time warning says so). +- The optional prices attribute the summarizer's own spend to the discussion's + cost ledger, where `maxCostPerDiscussion` sees it. +- The convergence judge's input is not windowed — it is already bounded to the + last two rounds. + Both caps are enforced independently: `maxPerTurn` bounds a runaway single turn, `maxAgentAddedTasksPerDiscussion` bounds slow drift across a long discussion. The 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. +## Voting + +A `VOTE` phase collects **explicit ballots** instead of another round of prose. +LLM ballots are correlated (shared priors, sycophancy) — the durable value is +the auditable artifact: the weighted tally, every raw ballot, and the losing +side's statements recorded as dissents on the `DecisionRecord`. + +```json +{ + "name": "Ballot", "type": "VOTE", + "turnOrder": "PARALLEL", "contextScope": "NONE", + "voteConfig": { + "method": "MAJORITY", + "optionsSource": "EXPLICIT", + "options": ["Adopt PostgreSQL", "Stay on MongoDB"], + "quorum": 0.5, + "weights": { "senior-architect": 2.0 }, + "weightByConfidence": false, + "tiePolicy": "MODERATOR_DECIDES" + } +} +``` + +**Independence is enforced structurally, not advised.** Save-time validation +rejects a VOTE phase that is not `PARALLEL` + `contextScope: NONE`; ballots are +cast blind against the pre-fan-out snapshot, and `VOTE` entries stay +peer-hidden until their phase completes (commit-reveal). + +- **Ballot contract:** `{"vote": "", "confidence": <0..1>, + "statement": "..."}` (`APPROVAL` uses `"votes": [...]`). Three-tier parse: + strict JSON → JSON embedded in prose → a reply naming exactly one option's + text. Anything else is a non-ballot and **counts against quorum** — as do + abstentions; a mostly-silent team has not reached quorum, and that is signal. +- **Options:** `EXPLICIT` is the reliable path. `LAST_SYNTHESIS` extracts + `Option A: …` lines from the newest synthesis — instruct that synthesis to + emit them. +- **Ties and quorum failures** go to `tiePolicy`: `MODERATOR_DECIDES` runs one + moderator turn choosing among the unresolved options (method + `vote+moderator-tiebreak`); `NO_DECISION` (default) records an honest + `type: NONE` and the discussion continues. `HUMAN_DECIDES` is reserved for + human group members (I6) and is rejected at save time until then. +- The result fires the `decision_reached` SSE event (which this feature also + wires for debate verdicts) and renders a tally block in Slack. + +## Humans as group members (I6) + +Real deployments are hybrid teams: a `memberType: "HUMAN"` member sits in the +roster like any agent, but their turn **pauses the discussion** +(`AWAITING_HUMAN_INPUT`) until they answer. + +```json +{ + "members": [ + { "agentId": "agent-1", "displayName": "Analyst", "speakingOrder": 1 }, + { "agentId": "gregor@example.com", "displayName": "Gregor", "speakingOrder": 2, "memberType": "HUMAN" } + ], + "humanMemberConfig": { "turnTimeout": "PT4H", "onTimeout": "SKIP_TURN" } +} +``` + +- The human's `agentId` is their **principal id** — the identity that may submit + their turns; `displayName` is required at save time. +- Their prompt is rendered exactly like an agent's and persisted on the + conversation (`pendingHumanInput.renderedPrompt`); the `human_input_requested` + SSE event (and a Slack notice) says who is up. +- Submission: `POST /groups/{groupId}/conversations/{id}/human-input` + `{memberId, content}` or MCP `submit_group_human_input`. **Only the member's + own principal (or an admin) may submit** — an `eddi-approver` may decide + approvals, but speaking as another human is impersonation, not review. The + answer is recorded as the phase's natural entry type (a human OPINION is an + OPINION) and the discussion resumes from the next speaker. +- This is deliberately NOT the approval surface: approve/reject endpoints never + accept free text, and the pending-approvals inbox marks these entries + `pauseType: "HUMAN_TURN"` with the member's id, so a human sees their own + pending turns without owning the conversation. +- **Timeouts** (`humanMemberConfig`): `turnTimeout` (ISO-8601; unset = wait + indefinitely) with `onTimeout: SKIP_TURN` (a SKIPPED entry — "no response from + within " — and the discussion moves on) or `ABORT` (graceful + cancel). Timeout schedules survive restarts via the HITL crash-recovery sweep. +- **PARALLEL phases**: agents fan out first; humans are then prompted one at a + time against the *pre-fan-out* snapshot, so an independent round stays + independent — a human answering after the agents cannot read their answers. +- **v1 bounds (save-time rejected)**: no HUMAN members in task-force groups + (PLAN/EXECUTE/VERIFY) or `targetEachPeer` phases, and a group containing + humans cannot be nested as a GROUP member. A human **moderator** is allowed — + every synthesis then waits on that person (the save warns about it). + +## Facilitator with bounded moves (I12) + +Orchestration is static choreography; nothing reacts ("we've agreed — stop", +"we need a specialist — recruit"). A full LLM orchestrator conflicts with +deterministic governance, so the facilitator chooses among +**config-enumerated moves** — validated, capped, audit-logged: + +```json +{ + "facilitator": { + "enabled": true, + "agentId": "facilitator-agent", + "allowedMoves": ["CONTINUE", "CALL_VOTE", "ESCALATE_HUMAN"], + "checkAfter": "EACH_PHASE", + "maxMovesPerDiscussion": 10, + "escalateTo": "gregor@example.com" + } +} +``` + +At each checkpoint (`EACH_PHASE` default, or `EACH_REPEAT`) the facilitator +agent receives a **compact briefing** — phase/repeat position, budget +arithmetic, roster, entry counts, capped excerpts, *never the full transcript* +— and replies `{"move", "args", "reason"}`. Moves v1: + +- `CONTINUE` — the ambient no-op and every failure's fallback. Silent. +- `END_PHASE` / `EXTEND_PHASE` — skip the remaining repeats / add one (≤2 + extensions per phase, still bounded by `maxTurns`). They act mid-phase, so + the save **rejects** them unless `checkAfter: EACH_REPEAT`. A convergence + exit is never overruled. +- `CALL_VOTE` — inserts a one-off VOTE phase next (`args.options`, 2–10), + built to I14's enforced PARALLEL+NONE shape by construction. +- `RECRUIT` — brings a deployed agent in (`args.agentId`), the same + validation path as I7's `recruitAgent` tool. +- `ESCALATE_HUMAN` — pauses the discussion (`AWAITING_HUMAN_INPUT`) for the + configured `escalateTo` principal with the facilitator's question, riding + I6's pending-input machinery whole; the answer records as a peer-visible + `FOLLOW_UP` and the discussion resumes. + +**Bounds and honesty.** The consult itself is a real LLM turn (counted, cost +on the I1 ledger, skipped once either budget is gone). Executed non-CONTINUE +moves are capped by `maxMovesPerDiscussion` (default 10) and each lands as a +peer-hidden `FACILITATION` entry + audit event + the +`eddi_group_facilitator_moves_total{move,outcome}` counter. Anything +unparseable, disallowed or invalid-in-context degrades to CONTINUE **and is +recorded as a rejected attempt** — the audit trail must show the model tried. +Facilitator failure never fails a discussion. + +**Runtime phase divergence.** CALL_VOTE and EXTEND_PHASE mutate a runtime +copy of the phase list, persisted on the conversation (schema v4). Every +resume path — approval, human turn, timeout skip — executes and drift-checks +against that list, so a pause taken inside an inserted phase resumes +correctly. The divergence is one-off: completion (and every new round) starts +from the config again. +## 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": ""|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"}`. +### Bid-based assignment (I18, CNP-lite) + +The planner cannot know members' actual fit or load. Setting +`assignmentMode: "BID"` — per task on a `TaskDefinition`, or as the group +default on `taskListConfig` — leaves those tasks unassigned at PLAN time; the +execution wave **announces** them to eligible members in blind, parallel bid +turns and **awards** each to the highest self-assessed confidence: + +```json +"taskListConfig": { "assignmentMode": "BID" }, +"tasks": [ + { "subject": "Write the migration", "description": "...", "assignmentMode": "BID" } +] +``` + +- Members reply `{"bids": [{"subject", "confidence": 0..1, "estimatedComplexity": + "XS|S|M|L", "rationale"}]}` — three-tier parse; prose casts no bids; bids on + unannounced tasks are dropped; confidence is clamped. +- **Blind**: the bid prompt carries the announced tasks and nothing else — no + transcript, no peer bids. Bid replies land as `BID` transcript entries + (peer-hidden while the phase runs, auditable afterwards). +- **Deterministic award**: highest confidence; ties break by speaking order, + then agent id. The winning bid is recorded per task + (`taskList.awardedBids[taskId] = {agentId, confidence, estimatedComplexity, + rationale}`). +- **Never stalls a wave**: a task nobody bid on falls back to the ROLE path, + and the auction skips itself entirely (logged) when it cannot beat its own + overhead — fewer than 2 eligible bidders or fewer than 2 unassigned tasks — + or when the remaining turn budget cannot cover one bid turn per member. +- Bid turns are real member turns: they count toward the turn budget and their + cost lands in the discussion's cost attribution. +## Retro → group memory + +A `RETRO` phase stops discussions from evaporating: the group reviews how it +worked and distills lessons that persist as **team-owned group memory**, then +surface as `{properties.*}` in every member's later discussions — institutional +knowledge that compounds run-over-run. + +```json +{ "name": "Retro", "type": "RETRO", "participants": "MODERATOR" }, +"retroConfig": { "maxLessonsPerRun": 3, "maxStoredLessons": 50 } +``` + +- The built-in template asks for `{"lessons": [{"lesson": "...", "context": + "..."}]}`; parsing is three-tier (strict JSON → embedded JSON → nothing) and + the per-run cap is enforced at parse time whatever the model produced. +- Lessons are stored under the synthetic owner `group:` with `group` + visibility — they belong to the team, not to whichever human ran the + discussion, and survive that human's GDPR erasure without carrying their + identity. The idempotency key `retro:` makes re-running the + same retro a no-op. +- **Bounded growth is non-negotiable:** the stored set FIFOs at + `maxStoredLessons` — storing past the cap evicts the oldest. +- Member conversations already load group-visible entries at init, so recall + needs no new namespace. The `retro_recorded` SSE event reports each harvest. + +## Standing Teams (I13) + +A group *conversation* is an episode; a **team** persists. The `GroupWorkspace` +(one document per group, own collection) holds what survives between episodes: +a **backlog** (a `SharedTaskList`, so pulled tasks flow straight into the +task-force machinery), **cadences** that pull from it on a schedule, and the +team's running **metrics** — deliberately thin glue over the existing +schedulers, I1's cost ledger and I8's retro memory. + +``` +POST /groupstore/groups/{groupId}/workspace/backlog {"subject": "...", "description": "...", "priority": 5} +POST /groupstore/groups/{groupId}/workspace/cadences {"cronExpression": "0 9 * * 1", "maxBacklogTasksPerRun": 5, "maxCostPerRun": 2.50} +GET /groupstore/groups/{groupId}/workspace +``` + +MCP: `add_team_task`, `list_team_backlog`. The backlog caps at 200 with an +actionable error — it is a working set, not an archive. + +**Cadence fires ride the schedule machinery whole** (`SchedulePollerService` +claim/lease/retry/dead-letter; the executor branches on +`teamCadenceType: "team_cadence"` exactly like Dream consolidation). Each fire: + +1. **Reconcile** — a finished previous run is written back first; one still in + flight (or paused at an HITL gate for days) skips the fire. +2. **Pull** — top-N *executable* backlog tasks by priority; an empty pull + skips, logged. +3. **Claim** — a conditional store write on `runningDiscussionId`; two pods + firing concurrently cannot both start, without any in-JVM lock. +4. **Run** — pulled tasks are injected as a runtime copy of `config.tasks` + (the stored config is never written) and the cadence's `maxCostPerRun` + rides the inherited-ceiling slot: dollar-primary, per the Dream precedent. + The discussion runs under the cadence *creator's* identity. + +**Writeback** happens at the next fire (or on a workspace read — read-repair), +never from inside the discussion thread, so a pod crash mid-discussion loses +nothing. VERIFIED outcomes stay VERIFIED on the backlog and credit the +assignee's `perMemberStats`; anything else returns to PENDING with the +reviewer's feedback appended to the description — **the cross-run retry +loop**. A FAILED/CANCELLED discussion returns every pulled task untouched. +Retro lessons flow through I8 unchanged (no duplication). + +Per-member stats are reliability **recording only** — nothing routes or +weights on them in v1. A *permanent* group deletion cascades to the workspace; +a soft (versioned) delete keeps it, because the group can come back. Not in +v1: cross-team handoff, Manager UI, reputation weighting. +## Shared artifacts (blackboard-lite) + +Without artifacts, the transcript is the only medium — every structured thing an +agent produces is prose the next agent re-parses. `artifactConfig` gives members +four tools to **create together**: `createArtifact(name, type, content)`, +`readArtifact(nameOrId)`, `proposeArtifactUpdate(nameOrId, content, +expectedVersion, markFinal?)` and `listArtifacts()`. Artifacts are typed +documents (`TEXT`, `MARKDOWN`, `JSON`) in their own collection, listed on the +discussion's REST/MCP status payload as `artifacts`, and announced over SSE and +Slack as `artifact_updated` events. + +```json +"artifactConfig": { + "allowArtifactTools": true, + "maxArtifactsPerDiscussion": 5, + "validators": [ + { "kind": "JSON_SCHEMA", "spec": "{\"type\":\"object\",\"required\":[\"title\"]}" }, + { "kind": "MAX_LENGTH", "spec": "20000" } + ] +} +``` + +**Concurrency is deterministic compare-and-set, not an LLM merge.** Every update +presents the version it read; a stale writer is told *"artifact changed since +you read it (now v3); re-read and merge your change"* and retries against fresh +content. The failure mode is a retry, never a silent bad merge. + +**Validators are declarative only** — `JSON_SCHEMA`, `REGEX` (content must +contain a match), `MAX_LENGTH` (characters) — never code. Specs are checked at +config save time; at write time a failing validator refuses the write with its +message and stores nothing. Content is additionally capped at 256 KB per +artifact. + +Off by default with the same absence discipline as the task tools: no opt-in +means the tools are never assembled. The member agent's own +`enableBuiltInTools` switch still applies. `markFinal: true` freezes an +artifact — FINAL artifacts accept no further updates. Artifacts are deleted +with their discussion (close/delete cascade) and by GDPR erasure; the durable +trace of the work is the transcript. + +## Preset Templates (I10) + +Enterprises understand "research pod" and "decision board", not +`contextScope: OWN_FEEDBACK`. Packaged templates ship complete, validated +group configs whose member slots are **named roles** — instantiation just +assigns agents (or human principals) to them and saves through the normal +store path, so every save-time validation applies. + +``` +GET /groupstore/templates → manifests (id, title, roles) +GET /groupstore/templates/{id} → manifest + full config +POST /groupstore/templates/{id}/instantiate {"name": "...", "roleAssignments": {"researcher1": "", ...}} +``` + +MCP: `list_group_templates`, `create_group_from_template`. Missing or unknown +roles fail loudly, naming the template's real roles. + +| Template | Style | What it packages | +|---|---|---| +| `research-pod` | DELPHI-style CUSTOM | Blind estimates → anonymous convergence rounds (I2) → synthesis → retro into team memory (I8), windowed context (I9), dollar ceiling (I1) | +| `editorial-team` | CUSTOM | Writer drafts a shared artifact (I17), editors propose CAS-guarded updates, dissents recorded (I4) | +| `ops-task-force` | TASK_FORCE | Bid-based assignment (I18), agent-filed tasks (I5), specialist recruitment (I7), ceiling | +| `decision-board` | CUSTOM | Hybrid board with a HUMAN director (I6) deliberating and voting (I14); options distilled by the chair, ties to the chair | +| `negotiation-table` | NEGOTIATION | Typed two-party bargaining with concession ledger (I11); the arbiter role may be a human principal | + ## 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. diff --git a/docs/langchain.md b/docs/langchain.md index 28817ccd36..c136982548 100644 --- a/docs/langchain.md +++ b/docs/langchain.md @@ -519,6 +519,7 @@ Both stored shapes therefore keep working: a config that sets only `streamingTim | `maxBudgetPerConversation` | number | Ceiling on accumulated **tool** cost per conversation, in USD. Records cost; only refuses calls when `enforceBudget` is on | (unlimited) | | `enforceBudget` | boolean | Refuse tool calls once `maxBudgetPerConversation` is passed. **Opt-in** — a ceiling without it is report-only, and is named in a startup WARN | false (`eddi.tools.budget.enforce-by-default`) | | `toolPricing` | map | Per-call tool prices in USD. Keyed on the built-in slug (`{"websearch": 0.005}`) or on a single dispatch name (`{"searchNews": 0.01}`), which takes precedence — so one operation can be priced apart from its siblings | (built-in defaults) | +| `inputPricePer1M` / `outputPricePer1M` | number | Token prices in USD per 1M tokens for this task's **model calls**, feeding the conversation's tracked cost (and any group cost ceiling). Applies to non-cascade calls; a [model cascade](model-cascade.md) prices its steps via its own fields of the same name. Negative values fail deployment | (unpriced — $0) | | `enableToolCaching` | boolean | Cache tool results to reduce API calls | true | | `toolCacheScopes` | map | Per-tool cache partition: `user`/`conversation`/`global` | (all `user`) | | `defaultToolCacheScope` | string | Cache partition for tools without an override | `user` | diff --git a/planning/group-collaboration-NEXT.md b/planning/group-collaboration-NEXT.md index 3ed9ecf3f1..ced5e1b62e 100644 --- a/planning/group-collaboration-NEXT.md +++ b/planning/group-collaboration-NEXT.md @@ -5,7 +5,7 @@ > holds the **design** for each item and does not repeat status — go there only for the one > item you are about to build, using the section pointers below. > -> **Last updated:** 2026-08-04 · after PR [#626](https://github.com/labsai/EDDI/pull/626) (`refactor/group-service-split`) +> **Last updated:** 2026-08-07 · after the `fix/group-pre-feature-defects` branch (N1 + N2/I9 + N3, §2 below — all three done) --- @@ -21,7 +21,8 @@ builds on (F1–F6), and shipped six feature items. | **Wave 0** — F1 `LiveDiscussionRegistry`, F2 speaker-level `ResumePoint`, F3 `DecisionRecord`, F4 entry types + visibility, F5 cost ledger, F6 schema versioning | ✅ Done | | **Wave 1** — I1 cost ceiling, I2 convergence + early exit, I3 structured verdicts, I4 abstention + minority report | ✅ Done | | **Wave 2** — I5 agent-filed tasks, I7 recruitment + delegation timeout | ✅ Done | -| **Wave 2** — I6, I8, I9, I14, I17 | ⬜ Queued (see §3) | +| **Pre-feature defects** — N1 plain-call pricing, N2/I9 transcript windowing, N3 schema sentinel (§2) | ✅ Done (2026-08-07, `fix/group-pre-feature-defects`) | +| **Wave 2** — I6, I8, I14, I17 | ⬜ Queued (see §3) | | **Wave 3** — I10, I11, I12, I13, I18 | ⬜ Queued (see §3) | | **Excluded — do not start** — I15 cross-team process DAGs, I16 A2A remote members | ❌ Out of scope | @@ -32,7 +33,12 @@ tool surfaces → a `ToolSourceProvider`. --- -## 2. Do these three first +## 2. Do these three first — ✅ ALL DONE (2026-08-07, branch `fix/group-pre-feature-defects`) + +> N1, N2 and N3 below all shipped on that branch, each mutation-checked; the §4 +> nested-group cost gap was folded into N1 as planned. The section is kept for +> the reasoning record — do not redo any of it. Details in `docs/changelog.md` +> (entries of 2026-08-07). **Next up: §3's queue, starting with I17 / I14 / I8.** These are not new features. They are **defects in what already shipped**, and later items depend on them. Do them as one small PR before starting any Wave 2 feature. @@ -171,9 +177,8 @@ Recorded so they are not rediscovered. Fix opportunistically, or fold into a rel - **`decision_reached` never fires.** I3 writes `DecisionRecord`, but no producer calls the sink event, so verdicts reach REST/MCP and never SSE or Slack. `SlackGroupDiscussionListener.onDecisionReached` is dead code today. *(Natural fold-in: I14.)* -- **Nested-group cost is overwritten, not accumulated.** `accumulateNestedGroupCost` keys by - `agentId`, and each nested turn is a fresh child discussion starting at 0, so only the last - child's spend survives. *(Natural fold-in: N1.)* +- ~~**Nested-group cost is overwritten, not accumulated.**~~ ✅ Fixed with N1 (2026-08-07): + attribution is keyed per child discussion (`agentId:childId`), so children sum. - **`allowAbstention` on a SYNTHESIS phase** yields a `COMPLETED` discussion with no answer. - **Parallel-phase late entries are lost.** After the batch deadline a member finishing milliseconds late has its real entry replaced by `SKIPPED`. **Both obvious fixes were tried diff --git a/pom.xml b/pom.xml index 08b6955d8c..6700ce946c 100644 --- a/pom.xml +++ b/pom.xml @@ -333,6 +333,13 @@ jsonschema-module-jackson 4.38.0 + + + com.networknt + json-schema-validator + 1.5.4 + jakarta.annotation jakarta.annotation-api diff --git a/src/main/java/ai/labs/eddi/configs/groups/ArtifactValidators.java b/src/main/java/ai/labs/eddi/configs/groups/ArtifactValidators.java new file mode 100644 index 0000000000..a7f0f27be5 --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/groups/ArtifactValidators.java @@ -0,0 +1,289 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.configs.groups; + +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.ArtifactConfig; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.ArtifactValidator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SchemaId; +import com.networknt.schema.SchemaLocation; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; +import org.jboss.logging.Logger; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import java.util.stream.Collectors; + +/** + * The declarative artifact validation chain (I17) — JSON Schema, regex and + * length checks a group config can gate artifact writes behind. Declarative + * only: there is deliberately no way to configure code execution here. + *

+ * Two entry points, one per failure audience: {@link #requireValidSpecs} runs + * at config save time and throws so a typo'd spec fails the save (the author's + * problem, at the moment they can fix it); {@link #firstRejection} runs at + * write time and returns a rejection sentence for the model (the write + * is refused, nothing is stored). + * + * @author ginccc + */ +public final class ArtifactValidators { + + /** Bound on schema violation messages quoted back to the model. */ + private static final int MAX_QUOTED_VIOLATIONS = 3; + + /** + * Validation-only mapper: parses candidate content and schema documents, never + * persists anything. + */ + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final JsonSchemaFactory SCHEMA_FACTORY = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012); + + /** + * The 2020-12 meta-schema, resolved from the library's bundled copy (no network + * I/O). {@code getSchema(spec)} alone only parses — it accepts schemas with + * invalid keyword values — so save-time validation additionally validates the + * spec as an instance against this. Null only if the bundled resource + * could not load, in which case save-time validation degrades to parse-only + * rather than rejecting every config. + */ + private static final JsonSchema META_SCHEMA; + + static { + JsonSchema metaSchema = null; + try { + metaSchema = SCHEMA_FACTORY.getSchema(SchemaLocation.of(SchemaId.V202012)); + } catch (Exception e) { + Logger.getLogger(ArtifactValidators.class) + .warnf("2020-12 meta-schema unavailable; schema specs are checked by parse only: %s", e.getMessage()); + } + META_SCHEMA = metaSchema; + } + + /** + * Wall-clock budget for one config-authored regex against one artifact's + * content. A catastrophically backtracking pattern must abort instead of + * pinning the member turn for its full timeout. + */ + private static final long REGEX_DEADLINE_NANOS = TimeUnit.MILLISECONDS.toNanos(500); + + private ArtifactValidators() { + } + + /** + * Save-time spec check: every validator must carry a kind and a spec its kind + * can actually use. Throws {@link IllegalArgumentException} (the same contract + * as {@code HitlConfigValidation}) so the config save fails with an actionable + * message rather than every future artifact write failing at runtime. + */ + public static void requireValidSpecs(ArtifactConfig config) { + if (config == null || config.validators().isEmpty()) { + return; + } + List validators = config.validators(); + for (int i = 0; i < validators.size(); i++) { + ArtifactValidator validator = validators.get(i); + String path = "artifactConfig.validators[" + i + "]"; + if (validator == null || validator.kind() == null) { + throw new IllegalArgumentException(path + " must name a kind (JSON_SCHEMA, REGEX or MAX_LENGTH)"); + } + String spec = validator.spec(); + if (spec == null || spec.isBlank()) { + throw new IllegalArgumentException(path + " (" + validator.kind() + ") needs a spec"); + } + switch (validator.kind()) { + case JSON_SCHEMA -> { + String problem = schemaSpecProblem(spec); + if (problem != null) { + throw new IllegalArgumentException(path + " is not a valid JSON schema: " + problem); + } + } + case REGEX -> { + try { + Pattern.compile(spec); + } catch (PatternSyntaxException e) { + throw new IllegalArgumentException(path + " is not a valid regex: " + e.getMessage()); + } + } + case MAX_LENGTH -> { + int max; + try { + max = Integer.parseInt(spec.trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(path + " (MAX_LENGTH) spec must be a positive integer, not '" + spec + "'"); + } + if (max <= 0) { + throw new IllegalArgumentException(path + " (MAX_LENGTH) must be > 0"); + } + } + } + } + } + + /** + * Write-time gate: runs the chain in config order and returns the first failure + * as a rejection sentence for the model, or {@code null} when every validator + * passes. A broken spec that slipped past save-time validation (hand-edited + * storage) rejects the write rather than admitting it — the gate fails closed. + */ + public static String firstRejection(List validators, String content) { + if (validators == null || validators.isEmpty()) { + return null; + } + String candidate = content != null ? content : ""; + for (ArtifactValidator validator : validators) { + if (validator == null || validator.kind() == null || validator.spec() == null) { + return "This discussion's artifact validation is misconfigured; the write was refused."; + } + String rejection = switch (validator.kind()) { + case MAX_LENGTH -> checkMaxLength(validator.spec(), candidate); + case REGEX -> checkRegex(validator.spec(), candidate); + case JSON_SCHEMA -> checkJsonSchema(validator.spec(), candidate); + }; + if (rejection != null) { + return rejection; + } + } + return null; + } + + private static String checkMaxLength(String spec, String content) { + int max; + try { + max = Integer.parseInt(spec.trim()); + } catch (NumberFormatException e) { + return "This discussion's artifact length validator is misconfigured; the write was refused."; + } + if (content.length() > max) { + return "The content is %d characters, over this discussion's %d-character limit for artifacts. Shorten it." + .formatted(content.length(), max); + } + return null; + } + + private static String checkRegex(String spec, String content) { + try { + if (!Pattern.compile(spec, Pattern.DOTALL).matcher(deadlineGuarded(content)).find()) { + return "The content does not match this discussion's required pattern for artifacts. Expected to find a match of: " + spec; + } + } catch (PatternSyntaxException e) { + return "This discussion's artifact pattern validator is misconfigured; the write was refused."; + } catch (MatchDeadlineExceededException e) { + return "This discussion's artifact pattern validator did not finish in time; the write was refused."; + } + return null; + } + + /** + * Save-time schema check, two layers: the spec must parse, and it must itself + * satisfy the 2020-12 meta-schema — {@code getSchema(spec)} alone accepts e.g. + * {@code {"type": "strng"}} and only misbehaves at write time. Returns the + * problem, or {@code null} for a valid spec. + */ + private static String schemaSpecProblem(String spec) { + JsonNode schemaNode; + try { + schemaNode = MAPPER.readTree(spec); + } catch (Exception e) { + return e.getMessage(); + } + if (META_SCHEMA != null) { + Set violations = META_SCHEMA.validate(schemaNode); + if (!violations.isEmpty()) { + return violations.stream() + .limit(MAX_QUOTED_VIOLATIONS) + .map(ValidationMessage::getMessage) + .collect(Collectors.joining("; ")); + } + } + try { + SCHEMA_FACTORY.getSchema(spec); + } catch (Exception e) { + return e.getMessage(); + } + return null; + } + + /** + * Thrown by {@link #deadlineGuarded}'s wrapper when the match budget is spent. + */ + private static final class MatchDeadlineExceededException extends RuntimeException { + MatchDeadlineExceededException() { + super("regex match exceeded its time budget", null, false, false); + } + } + + /** + * Wraps content so a regex match aborts once {@link #REGEX_DEADLINE_NANOS} is + * spent. Backtracking re-reads characters, so the deadline check in + * {@code charAt} (sampled, to keep the fast path cheap) is hit constantly by + * exactly the pathological patterns it exists to stop. Single-matcher use only + * — the sampling counter is not thread-safe, matching a Matcher's own contract. + */ + private static CharSequence deadlineGuarded(String content) { + long deadline = System.nanoTime() + REGEX_DEADLINE_NANOS; + return new CharSequence() { + private int accesses; + + @Override + public int length() { + return content.length(); + } + + @Override + public char charAt(int index) { + if ((++accesses & 0x3FF) == 0 && System.nanoTime() > deadline) { + throw new MatchDeadlineExceededException(); + } + return content.charAt(index); + } + + @Override + public CharSequence subSequence(int start, int end) { + return content.subSequence(start, end); + } + + @Override + public String toString() { + return content; + } + }; + } + + private static String checkJsonSchema(String spec, String content) { + JsonSchema schema; + try { + schema = SCHEMA_FACTORY.getSchema(spec); + } catch (Exception e) { + return "This discussion's artifact schema validator is misconfigured; the write was refused."; + } + JsonNode node; + try { + node = MAPPER.readTree(content); + } catch (Exception e) { + return "The content must be valid JSON to pass this discussion's artifact schema, and it is not. Fix the JSON and retry."; + } + Set violations = schema.validate(node); + if (!violations.isEmpty()) { + String quoted = violations.stream() + .limit(MAX_QUOTED_VIOLATIONS) + .map(ValidationMessage::getMessage) + .collect(Collectors.joining("; ")); + String suffix = violations.size() > MAX_QUOTED_VIOLATIONS + ? " (and " + (violations.size() - MAX_QUOTED_VIOLATIONS) + " more)" + : ""; + return "The content does not satisfy this discussion's artifact schema: " + quoted + suffix + ". Fix it and retry."; + } + return null; + } +} diff --git a/src/main/java/ai/labs/eddi/configs/groups/IGroupWorkspaceStore.java b/src/main/java/ai/labs/eddi/configs/groups/IGroupWorkspaceStore.java new file mode 100644 index 0000000000..3934fa1d46 --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/groups/IGroupWorkspaceStore.java @@ -0,0 +1,63 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.configs.groups; + +import ai.labs.eddi.configs.groups.model.GroupWorkspace; +import ai.labs.eddi.datastore.IResourceStore; + +/** + * Store for {@link GroupWorkspace} documents (I13) — one per group config id, + * own collection. + * + * @author ginccc + */ +public interface IGroupWorkspaceStore { + + /** + * The group's workspace, or {@code null} if none exists yet. Never creates. + */ + GroupWorkspace find(String groupId) throws IResourceStore.ResourceStoreException; + + /** + * The group's workspace, created empty on first access. Creation races are + * benign for an empty document: if two callers create concurrently, the later + * writer's empty workspace wins and neither loses data it had. + */ + GroupWorkspace readOrCreate(String groupId) throws IResourceStore.ResourceStoreException; + + void update(GroupWorkspace workspace) throws IResourceStore.ResourceStoreException; + + /** Deletes the group's workspace, if any. Idempotent. */ + void deleteByGroupId(String groupId) throws IResourceStore.ResourceStoreException; + + /** + * Atomically claims the workspace for one cadence discussion: writes + * {@code workspace} (which must already carry the new + * {@code runningDiscussionId} and pulled-task state) only if the PERSISTED + * {@code runningDiscussionId} still equals {@code expectedRunning}. Returns + * {@code false} when another pod won the claim (or released it) in between — + * the caller skips its run. + */ + boolean casRunningDiscussion(GroupWorkspace workspace, String expectedRunning) + throws IResourceStore.ResourceStoreException; + + /** + * Optimistic-concurrency write: persists {@code workspace} only if its + * {@code revision} still matches what this caller read, bumping it in the same + * write. Use for every read-modify-write surface (backlog adds) so two + * concurrent editors cannot silently drop each other's changes — the loser + * re-reads and retries. + *

+ * Known bound: a document created before the {@code revision} field existed + * carries {@code null} — the first write on such a document is a plain + * (unconditional) stamp, so two callers that BOTH read the pre-revision shape + * can still race that one write. The window is one write per legacy document; + * every write after the stamp is CAS'd. + * + * @return {@code true} if the write landed; {@code false} if a concurrent + * writer changed the workspace first (re-read before retrying) + */ + boolean casRevision(GroupWorkspace workspace) throws IResourceStore.ResourceStoreException; +} diff --git a/src/main/java/ai/labs/eddi/configs/groups/IRestGroupTemplates.java b/src/main/java/ai/labs/eddi/configs/groups/IRestGroupTemplates.java new file mode 100644 index 0000000000..08cc28ff86 --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/groups/IRestGroupTemplates.java @@ -0,0 +1,56 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.configs.groups; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +import java.util.Map; + +/** + * REST surface for org/team preset templates (I10): list what's available, read + * one, and instantiate it into a real group config by assigning agents to the + * template's named roles. + * + * @author ginccc + */ +@Path("/groupstore/templates") +@Tag(name = "13. Agent Groups", description = "Org/team preset templates") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@RolesAllowed({"eddi-admin", "eddi-editor"}) +public interface IRestGroupTemplates { + + /** + * @param roleAssignments + * template role → agent id (for HUMAN roles: the principal id) + */ + record InstantiateRequest(String name, Map roleAssignments) { + } + + @GET + @Operation(description = "List the packaged group templates: id, title, description and required roles.") + Response listTemplates(); + + @GET + @Path("/{templateId}") + @Operation(description = "Read one template — its manifest and the full config it instantiates.") + Response readTemplate(@PathParam("templateId") String templateId); + + @POST + @Path("/{templateId}/instantiate") + @Operation(description = "Create a group from a template by assigning agents to its roles. " + + "Runs the normal store path — every save-time validation applies.") + Response instantiate(@PathParam("templateId") String templateId, InstantiateRequest request); +} diff --git a/src/main/java/ai/labs/eddi/configs/groups/IRestGroupWorkspace.java b/src/main/java/ai/labs/eddi/configs/groups/IRestGroupWorkspace.java new file mode 100644 index 0000000000..46ef4c7a3f --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/groups/IRestGroupWorkspace.java @@ -0,0 +1,74 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.configs.groups; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +/** + * REST surface for a group's standing workspace (I13): the backlog human PMs + * feed, the cadences that pull from it, and the team's running metrics. + * + * @author ginccc + */ +@Path("/groupstore/groups/{groupId}/workspace") +@Tag(name = "13. Agent Groups", description = "Standing team workspace — backlog, cadences, metrics") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@RolesAllowed({"eddi-admin", "eddi-editor"}) +public interface IRestGroupWorkspace { + + /** + * A backlog task as a human PM files it. + * + * @param priority + * higher runs earlier when a cadence pulls + */ + record BacklogTaskRequest(String subject, String description, int priority) { + } + + /** + * A cadence definition. {@code cronExpression} is the standard 5-field form the + * schedule store already speaks. + */ + record CadenceRequest(String cronExpression, String timeZone, String inputTemplate, + int maxBacklogTasksPerRun, Double maxCostPerRun) { + } + + @GET + @Operation(description = "Read the group's workspace (backlog, cadences, metrics). " + + "Settles a finished cadence discussion on read, so metrics are current.") + Response readWorkspace(@PathParam("groupId") String groupId); + + @GET + @Path("/backlog") + @Operation(description = "List the backlog tasks.") + Response readBacklog(@PathParam("groupId") String groupId); + + @POST + @Path("/backlog") + @Operation(description = "Add a task to the backlog (cap: 200 — the backlog is a working set, not an archive).") + Response addBacklogTask(@PathParam("groupId") String groupId, BacklogTaskRequest request); + + @POST + @Path("/cadences") + @Operation(description = "Add a cadence: a cron-scheduled pull of executable backlog tasks into a group discussion.") + Response addCadence(@PathParam("groupId") String groupId, CadenceRequest request); + + @DELETE + @Path("/cadences/{cadenceId}") + @Operation(description = "Remove a cadence and its schedule.") + Response deleteCadence(@PathParam("groupId") String groupId, @PathParam("cadenceId") String cadenceId); +} diff --git a/src/main/java/ai/labs/eddi/configs/groups/ISharedArtifactStore.java b/src/main/java/ai/labs/eddi/configs/groups/ISharedArtifactStore.java new file mode 100644 index 0000000000..2f45189042 --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/groups/ISharedArtifactStore.java @@ -0,0 +1,80 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.configs.groups; + +import ai.labs.eddi.configs.groups.model.SharedArtifact; +import ai.labs.eddi.datastore.IResourceStore; + +import java.util.List; + +/** + * Store for {@link SharedArtifact}s (I17) — runtime documents in their own + * collection, one per co-edited artifact of a group discussion. Same + * single-version, DB-agnostic shape as {@link IGroupConversationStore}. + * + * @author ginccc + */ +public interface ISharedArtifactStore { + + /** + * Persists a new artifact and assigns its id. + * + * @return the new artifact's id + */ + String create(SharedArtifact artifact) throws IResourceStore.ResourceStoreException; + + SharedArtifact read(String id) throws IResourceStore.ResourceNotFoundException, IResourceStore.ResourceStoreException; + + /** + * Persists {@code artifact} only if the stored document's {@code version} still + * equals {@code expectedVersion} — the deterministic CAS every accepted edit + * goes through. The caller applies the edit (which bumps the in-memory version) + * and presents the version it read. + * + * @throws IResourceStore.ResourceModifiedException + * lost the race — someone else's edit landed first (retry after a + * fresh read) + * @throws ArtifactGoneException + * the artifact was deleted concurrently + */ + void updateIfVersion(SharedArtifact artifact, long expectedVersion) + throws IResourceStore.ResourceStoreException, IResourceStore.ResourceModifiedException; + + void delete(String id) throws IResourceStore.ResourceStoreException; + + /** + * All artifacts of one discussion, oldest first. Bounded by the group config's + * {@code maxArtifactsPerDiscussion}, so no pagination surface. + */ + List listByGroupConversationId(String groupConversationId) throws IResourceStore.ResourceStoreException; + + /** + * Cascade delete for a closing/deleted discussion. + * + * @return how many artifacts were removed + */ + long deleteByGroupConversationId(String groupConversationId) throws IResourceStore.ResourceStoreException; + + /** + * GDPR erasure sweep: permanently removes every artifact whose + * {@code ownerUserId} is {@code userId}. Follows the group-conversation store's + * erasure contract — pages until an empty pass, re-checks ownership by exact + * match in Java, and throws rather than reporting a partial erasure as success. + * + * @return how many artifacts were removed + */ + long deleteAllForUser(String userId) throws IResourceStore.ResourceStoreException; + + /** + * Unchecked "deleted concurrently" — thrown by {@link #updateIfVersion} so CAS + * call sites can distinguish a retryable conflict (409) from a gone document + * (404) without a checked-exception cascade. + */ + class ArtifactGoneException extends RuntimeException { + public ArtifactGoneException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/src/main/java/ai/labs/eddi/configs/groups/model/AgentGroupConfiguration.java b/src/main/java/ai/labs/eddi/configs/groups/model/AgentGroupConfiguration.java index 40da7c9d0e..51c3dc18bc 100644 --- a/src/main/java/ai/labs/eddi/configs/groups/model/AgentGroupConfiguration.java +++ b/src/main/java/ai/labs/eddi/configs/groups/model/AgentGroupConfiguration.java @@ -11,6 +11,7 @@ import jakarta.validation.constraints.Size; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; @@ -91,6 +92,157 @@ public void setTaskListConfig(GroupTaskConfig taskListConfig) { this.taskListConfig = taskListConfig; } + /** + * How HUMAN members' turns are timed out (I6). {@code null} means wait + * indefinitely — the same thing a default-constructed {@link HumanMemberConfig} + * means. + */ + private HumanMemberConfig humanMemberConfig; + + public HumanMemberConfig getHumanMemberConfig() { + return humanMemberConfig; + } + + public void setHumanMemberConfig(HumanMemberConfig humanMemberConfig) { + this.humanMemberConfig = humanMemberConfig; + } + + /** + * Bounds for RETRO lessons (I8). {@code null} runs a RETRO phase with the + * defaults — the caps are non-negotiable either way (bounded growth for an LLM + * write surface). + */ + private RetroConfig retroConfig; + + public RetroConfig getRetroConfig() { + return retroConfig; + } + + public void setRetroConfig(RetroConfig retroConfig) { + this.retroConfig = retroConfig; + } + + /** + * Bounds for the RETRO lesson pipeline (I8). + * + * @param maxLessonsPerRun + * how many lessons one retro turn may store (default 3) — the + * template quotes this cap to the model, and the parser truncates + * past it regardless + * @param maxStoredLessons + * FIFO ceiling on the team's stored lessons (default 50): storing + * the 51st evicts the oldest. Bounded growth is non-negotiable — + * non-positive values fall back to the defaults + */ + public record RetroConfig(int maxLessonsPerRun, int maxStoredLessons) { + + public static final int DEFAULT_MAX_PER_RUN = 3; + public static final int DEFAULT_MAX_STORED = 50; + /** + * Hard ceilings (review finding): the compact constructor accepted any positive + * int, so a config carrying {@code Integer.MAX_VALUE} made the per-run write + * count and the retained-lesson count effectively unbounded — exactly the + * bounded-growth guarantee this record exists to enforce on an LLM-driven write + * surface. + */ + public static final int CEILING_MAX_PER_RUN = 20; + public static final int CEILING_MAX_STORED = 500; + + /** Same normalization choke point as {@link GroupTaskConfig}. */ + public RetroConfig { + if (maxLessonsPerRun <= 0) { + maxLessonsPerRun = DEFAULT_MAX_PER_RUN; + } + if (maxStoredLessons <= 0) { + maxStoredLessons = DEFAULT_MAX_STORED; + } + maxLessonsPerRun = Math.min(maxLessonsPerRun, CEILING_MAX_PER_RUN); + maxStoredLessons = Math.min(maxStoredLessons, CEILING_MAX_STORED); + } + + /** Both caps at their defaults. */ + public RetroConfig() { + this(DEFAULT_MAX_PER_RUN, DEFAULT_MAX_STORED); + } + } + + /** + * Transcript windowing for rendered member context (I9). {@code null} means + * windowing is off — every FULL/ANONYMOUS-scope turn renders the whole + * scope-filtered transcript, exactly as before the feature existed. + */ + private ContextWindowConfig contextWindow; + + public ContextWindowConfig getContextWindow() { + return contextWindow; + } + + public void setContextWindow(ContextWindowConfig contextWindow) { + this.contextWindow = contextWindow; + } + + /** + * Bounds what a FULL/ANONYMOUS-scope member turn renders from the transcript + * (I9). Without it, every such turn re-feeds the entire transcript to every + * member — ~quadratic cost as the discussion grows. When the scope-filtered + * context exceeds {@link #maxRecentEntries}, the rendered context becomes + * {@code [summary of the older entries] + [the recent entries verbatim]} — a + * derived view only; the stored transcript is never modified and signing + * verification still operates on the raw entries. + * + * @param enabled + * master switch, default off — a config that does not opt in renders + * exactly as before + * @param maxRecentEntries + * how many of the newest scope-filtered entries stay verbatim. + * Non-positive falls back to {@link #DEFAULT_MAX_RECENT_ENTRIES} (an + * accidental 0 must not blank the whole context) + * @param summarizeOverflow + * {@code true} (default) compresses the older entries into a rolling + * summary via the shared summarization service; {@code false} + * replaces them with a plain "[n earlier entries omitted]" marker + * and never calls an LLM + * @param llmProvider + * provider for the summarization calls (e.g. "openai"). Required for + * summarization — without it (or {@code llmModel}) the overflow + * falls back to the truncation marker with a warning + * @param llmModel + * model name for the summarization calls + * @param inputPricePer1M + * optional USD price per 1M input tokens for the summarizer's own + * calls, so their cost counts toward the discussion's I1 ledger. + * Null or negative = unpriced ($0), same semantics as the LLM task + * pricing fields + * @param outputPricePer1M + * optional USD price per 1M output tokens, same semantics + */ + public record ContextWindowConfig(boolean enabled, int maxRecentEntries, Boolean summarizeOverflow, + String llmProvider, String llmModel, + Double inputPricePer1M, Double outputPricePer1M) { + + public static final int DEFAULT_MAX_RECENT_ENTRIES = 30; + + /** + * Normalizes at the one choke point every reader passes through — same shape as + * {@link GroupTaskConfig}. {@code summarizeOverflow} is a Boolean rather than a + * boolean so an omitted JSON key defaults to {@code true} instead of silently + * disabling summarization. + */ + public ContextWindowConfig { + if (maxRecentEntries <= 0) { + maxRecentEntries = DEFAULT_MAX_RECENT_ENTRIES; + } + summarizeOverflow = summarizeOverflow == null || summarizeOverflow; + // Blank identifiers ARE absent — every reader null-checks, and a + // whitespace-only provider reaching the summarization service would + // bypass the documented truncation fallback. + llmProvider = llmProvider == null || llmProvider.isBlank() ? null : llmProvider; + llmModel = llmModel == null || llmModel.isBlank() ? null : llmModel; + inputPricePer1M = inputPricePer1M == null || inputPricePer1M < 0 ? null : inputPricePer1M; + outputPricePer1M = outputPricePer1M == null || outputPricePer1M < 0 ? null : outputPricePer1M; + } + } + /** * Governs agent-filed tasks (I5). The task list is otherwise written only by * the PLAN phase and by config, so work an agent discovers while @@ -118,7 +270,8 @@ public void setTaskListConfig(GroupTaskConfig taskListConfig) { * files twenty tasks in one breath, where a discussion-long drift * files one per turn for forty turns */ - public record GroupTaskConfig(boolean allowAgentTaskCreation, int maxAgentAddedTasksPerDiscussion, int maxPerTurn) { + public record GroupTaskConfig(boolean allowAgentTaskCreation, int maxAgentAddedTasksPerDiscussion, int maxPerTurn, + AssignmentMode assignmentMode) { public static final int DEFAULT_MAX_PER_DISCUSSION = 20; public static final int DEFAULT_MAX_PER_TURN = 3; @@ -136,6 +289,14 @@ public record GroupTaskConfig(boolean allowAgentTaskCreation, int maxAgentAddedT if (maxPerTurn <= 0) { maxPerTurn = DEFAULT_MAX_PER_TURN; } + if (assignmentMode == null) { + assignmentMode = AssignmentMode.ROLE; + } + } + + /** Backward-compatible constructor without {@code assignmentMode} (I18). */ + public GroupTaskConfig(boolean allowAgentTaskCreation, int maxAgentAddedTasksPerDiscussion, int maxPerTurn) { + this(allowAgentTaskCreation, maxAgentAddedTasksPerDiscussion, maxPerTurn, AssignmentMode.ROLE); } /** Disabled, with both caps at their defaults. */ @@ -144,6 +305,75 @@ public GroupTaskConfig() { } } + /** + * A member of the group. Members can be individual agents, nested groups, or + * humans (I6). Whether members may create and co-edit shared artifacts + * mid-discussion (I17). {@code null} means the artifact tools are absent + * entirely. + */ + private ArtifactConfig artifactConfig; + + public ArtifactConfig getArtifactConfig() { + return artifactConfig; + } + + public void setArtifactConfig(ArtifactConfig artifactConfig) { + this.artifactConfig = artifactConfig; + } + + /** + * Governs shared artifacts (I17, blackboard-lite): typed documents members + * co-edit through tools instead of re-parsing each other's prose. Same + * opt-in-by-absence discipline as {@link GroupTaskConfig}: off means the tools + * are never assembled, and there is no permissive standalone default. + * + * @param allowArtifactTools + * master switch, default off + * @param maxArtifactsPerDiscussion + * ceiling on artifacts per discussion (default 5). Non-positive + * falls back to the default — 0 must never mean unlimited for an LLM + * write surface + * @param validators + * declarative validation chain every accepted write must pass — + * {@link ValidatorKind#JSON_SCHEMA}, {@link ValidatorKind#REGEX} or + * {@link ValidatorKind#MAX_LENGTH} with a {@code spec}. Declarative + * only, never arbitrary code. Failed validation rejects the + * write with the validator's message; nothing is stored + */ + public record ArtifactConfig(boolean allowArtifactTools, int maxArtifactsPerDiscussion, List validators) { + + public static final int DEFAULT_MAX_ARTIFACTS = 5; + + /** Same normalization choke point as {@link GroupTaskConfig}. */ + public ArtifactConfig { + if (maxArtifactsPerDiscussion <= 0) { + maxArtifactsPerDiscussion = DEFAULT_MAX_ARTIFACTS; + } + // Not List.copyOf: it NPEs on a null ELEMENT ("validators": [null]), + // preempting ArtifactValidators.requireValidSpecs' actionable message. + validators = validators == null ? List.of() : Collections.unmodifiableList(new ArrayList<>(validators)); + } + + /** Disabled, cap at its default, no validators. */ + public ArtifactConfig() { + this(false, DEFAULT_MAX_ARTIFACTS, List.of()); + } + } + + /** + * One declarative artifact validator (I17): {@code kind} selects the check, + * {@code spec} parameterizes it — a JSON schema document, a regex the content + * must match, or a maximum character count. Specs are validated at save time so + * a typo fails the config save, not a member's turn. + */ + public record ArtifactValidator(ValidatorKind kind, String spec) { + } + + /** The closed set of declarative artifact validators (I17). */ + public enum ValidatorKind { + JSON_SCHEMA, REGEX, MAX_LENGTH + } + /** * A member of the group. Members can be individual agents or nested groups. *

@@ -151,6 +381,11 @@ public GroupTaskConfig() { * group configuration ID instead. The sub-group runs its own discussion and its * synthesized answer becomes this member's response. *

+ * For {@code MemberType.HUMAN} members, {@code agentId} carries the human's + * principal id (the identity that may submit their turns) and + * {@code displayName} is required at save time — a paused discussion must be + * able to say WHO it is waiting on. + *

* The optional {@code role} field controls which phases the member participates * in (e.g. "DEVIL_ADVOCATE", "PRO", "CON"). If null, the member is a default * participant. @@ -164,13 +399,169 @@ public GroupMember(String agentId, String displayName, Integer speakingOrder, St } /** - * Whether a group member is an individual agent or a nested sub-group. + * Whether a group member is an individual agent, a nested sub-group, or a human + * (I6). */ public enum MemberType { /** An individual EDDI agent. */ AGENT, /** A nested group — runs its own discussion, returns synthesized answer. */ - GROUP + GROUP, + /** + * A human — their turn pauses the discussion ({@code + * AWAITING_HUMAN_INPUT}) until they submit a response or the group's + * {@code humanMemberConfig} timeout policy resolves the turn (I6). + */ + HUMAN + } + + /** + * How the discussion treats HUMAN members' turns (I6). One config for the whole + * group: humans on the same team wait under the same rules. + * + * @param turnTimeout + * ISO-8601 duration a human turn may stay unanswered before + * {@code onTimeout} fires; {@code null} or blank = wait indefinitely + * @param onTimeout + * what an expired turn does — defaults to + * {@link OnHumanTimeout#SKIP_TURN} + */ + public record HumanMemberConfig(String turnTimeout, OnHumanTimeout onTimeout) { + + /** Normalization choke point, same shape as {@link GroupTaskConfig}. */ + public HumanMemberConfig { + if (onTimeout == null) { + onTimeout = OnHumanTimeout.SKIP_TURN; + } + } + + /** Wait indefinitely; a timeout would skip the turn if one were set. */ + public HumanMemberConfig() { + this(null, OnHumanTimeout.SKIP_TURN); + } + } + + /** What an expired human turn does (I6). */ + public enum OnHumanTimeout { + /** + * Record a SKIPPED entry ("no response from within ") and move on. + */ + SKIP_TURN, + /** Cancel the discussion. */ + ABORT + } + + /** + * A facilitator agent's checkpoint configuration (I12). {@code null} means no + * facilitator — the same thing a default-constructed instance means. + */ + private FacilitatorConfig facilitator; + + public FacilitatorConfig getFacilitator() { + return facilitator; + } + + public void setFacilitator(FacilitatorConfig facilitator) { + this.facilitator = facilitator; + } + + /** + * Bounded adaptive orchestration (I12): a facilitator agent is briefed at + * configured checkpoints and chooses one move from a config-enumerated list. A + * full LLM orchestrator conflicts with deterministic governance — the + * facilitator never free-forms; it selects, and every selection is validated, + * capped and audit-logged. Anything unparseable, disallowed or invalid degrades + * to {@link FacilitatorMove#CONTINUE}. + * + * @param enabled + * master switch; {@code false} (default) means no checkpoint ever + * runs and the discussion costs nothing extra + * @param agentId + * the agent that plays facilitator — required when enabled. It runs + * under its own conversation key, never a member's + * @param allowedMoves + * the moves the facilitator may execute; defaults to + * {@code [CONTINUE]}, which makes an enabled-but-unconfigured + * facilitator a pure observer. {@code END_PHASE} and + * {@code EXTEND_PHASE} act mid-phase and are save-time rejected + * unless {@code checkAfter} is {@code EACH_REPEAT} + * @param checkAfter + * checkpoint cadence — after each completed phase (default) or after + * each completed repeat within a phase + * @param maxMovesPerDiscussion + * ceiling on executed non-CONTINUE moves across the whole + * discussion (default 10). Rejected attempts do not consume it — + * they are recorded, not budgeted + * @param escalateTo + * principal id that {@link FacilitatorMove#ESCALATE_HUMAN} pauses + * for — required at save time when that move is allowed + */ + public record FacilitatorConfig(boolean enabled, String agentId, List allowedMoves, + FacilitatorCheckpoint checkAfter, int maxMovesPerDiscussion, String escalateTo) { + + public static final int DEFAULT_MAX_MOVES = 10; + + /** Normalization choke point, same shape as {@link GroupTaskConfig}. */ + public FacilitatorConfig { + allowedMoves = allowedMoves == null || allowedMoves.isEmpty() + ? List.of(FacilitatorMove.CONTINUE) + : List.copyOf(allowedMoves); + if (checkAfter == null) { + checkAfter = FacilitatorCheckpoint.EACH_PHASE; + } + if (maxMovesPerDiscussion <= 0) { + maxMovesPerDiscussion = DEFAULT_MAX_MOVES; + } + } + + /** Disabled, observer-only move list, phase-boundary cadence. */ + public FacilitatorConfig() { + this(false, null, List.of(FacilitatorMove.CONTINUE), FacilitatorCheckpoint.EACH_PHASE, DEFAULT_MAX_MOVES, null); + } + } + + /** + * The moves a facilitator may choose from (I12). The move list IS the feature + * surface — each non-CONTINUE move drives machinery another item already built + * (I2's phase-exit plumbing, I14's vote phases, I7's recruitment, I6's pending + * human input). + */ + public enum FacilitatorMove { + /** No intervention — the ambient default and every failure's fallback. */ + CONTINUE, + /** Skip the current phase's remaining repeats. Mid-phase only. */ + END_PHASE, + /** + * Add one repeat to the current phase (at most 2 extensions per phase, still + * bounded by {@code maxTurns}). Mid-phase only. + */ + EXTEND_PHASE, + /** + * Insert a one-off VOTE phase right after the current one, with the options the + * facilitator names in {@code args.options}. + */ + CALL_VOTE, + /** + * Recruit an existing deployed agent into the roster — the same validation path + * as I7's {@code recruitAgent} tool. + */ + RECRUIT, + /** + * Pause the discussion for input from the configured {@code escalateTo} + * principal, carrying the facilitator's question (I6 pending-input machinery). + */ + ESCALATE_HUMAN + } + + /** When the facilitator is briefed (I12). */ + public enum FacilitatorCheckpoint { + /** After each completed phase — the default. */ + EACH_PHASE, + /** + * After each completed repeat within a phase. Required for + * {@code END_PHASE}/{@code EXTEND_PHASE}, which act mid-phase. + */ + EACH_REPEAT } // --- Discussion Style --- @@ -192,6 +583,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 } @@ -208,7 +605,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, VoteConfig voteConfig, PhaseSkipCondition skipIf) { /** * Convenience constructor with defaults: participants=ALL, @@ -248,6 +645,125 @@ public DiscussionPhase(String name, PhaseType type, String participants, TurnOrd boolean targetEachPeer, String inputTemplate, int repeats, boolean requiresApproval, ConvergenceConfig convergence) { this(name, type, participants, turnOrder, contextScope, targetEachPeer, inputTemplate, repeats, requiresApproval, convergence, false); } + + /** + * Backward-compatible constructor without {@code voteConfig} (I14) or + * {@code skipIf} (I11) — {@code null}s mean a VOTE phase runs with + * {@link VoteConfig}'s defaults and the phase always runs, the behavior of + * every phase that predates those items. + */ + 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, null); + } + + /** + * I14-shaped constructor without {@code skipIf} — what every vote-phase call + * site written before the I11 merge uses. + */ + public DiscussionPhase(String name, PhaseType type, String participants, TurnOrder turnOrder, ContextScope contextScope, + boolean targetEachPeer, String inputTemplate, int repeats, boolean requiresApproval, ConvergenceConfig convergence, + boolean allowAbstention, VoteConfig voteConfig) { + this(name, type, participants, turnOrder, contextScope, targetEachPeer, inputTemplate, repeats, requiresApproval, convergence, + allowAbstention, voteConfig, null); + } + + /** + * I11-shaped constructor without {@code voteConfig} — what every + * negotiation-phase call site written before the I14 merge uses. + */ + public DiscussionPhase(String name, PhaseType type, String participants, TurnOrder turnOrder, ContextScope contextScope, + boolean targetEachPeer, String inputTemplate, int repeats, boolean requiresApproval, ConvergenceConfig convergence, + boolean allowAbstention, PhaseSkipCondition skipIf) { + this(name, type, participants, turnOrder, contextScope, targetEachPeer, inputTemplate, repeats, requiresApproval, convergence, + allowAbstention, null, skipIf); + } + } + + /** + * Ballot rules for a {@link PhaseType#VOTE} phase (I14). + *

+ * LLM ballots are correlated — shared priors, sycophancy — so the + * durable value of a vote is the auditable process artifact (tally, raw + * ballots, losing-side dissents), not the epistemics. Independence is + * engineered structurally: save-time validation forces VOTE phases to + * {@code PARALLEL} + {@code ContextScope.NONE}, so ballots are cast blind + * against the pre-fan-out transcript snapshot — commit-reveal for LLM purposes, + * not an instruction the model could ignore. + * + * @param method + * {@code MAJORITY} — one option per ballot, highest weighted count + * wins; {@code APPROVAL} — a ballot may approve several options. + * Default MAJORITY + * @param optionsSource + * where the ballot options come from. {@code EXPLICIT} (the reliable + * path) takes {@code options} verbatim; {@code LAST_SYNTHESIS} + * extracts {@code Option A: …} lines from the latest SYNTHESIS entry + * — instruct the synthesis to emit that shape. Default + * LAST_SYNTHESIS + * @param options + * the explicit option texts, required (≥ 2) for EXPLICIT + * @param quorum + * the fraction of participants that must cast a valid ballot for the + * vote to decide, in (0, 1]. Abstentions and unparseable replies + * count toward the denominator only — a mostly-silent team has NOT + * reached quorum, and that is signal. Out-of-range values fall back + * to the default 0.5 + * @param weights + * per-agentId ballot weights, default 1.0 each. Negative weights are + * rejected at save time + * @param weightByConfidence + * multiply each ballot by its self-reported 0..1 confidence + * (ReConcile-style). Default off — self-reported confidence is + * exactly as correlated as the ballots themselves; treat the + * weighted tally as process record, not probability + * @param tiePolicy + * what resolves a tie or a quorum failure: {@code MODERATOR_DECIDES} + * (one moderator turn choosing among the tied options), + * {@code HUMAN_DECIDES} (reserved for I6 human members — rejected at + * save time until that ships), or {@code NO_DECISION} (default: + * record {@code type=NONE} and carry on) + */ + public record VoteConfig(VoteMethod method, OptionsSource optionsSource, List options, double quorum, + Map weights, boolean weightByConfidence, TiePolicy tiePolicy) { + + public static final double DEFAULT_QUORUM = 0.5; + + /** Same normalization choke point as {@link GroupTaskConfig}. */ + public VoteConfig { + method = method == null ? VoteMethod.MAJORITY : method; + optionsSource = optionsSource == null ? OptionsSource.LAST_SYNTHESIS : optionsSource; + options = options == null ? List.of() : List.copyOf(options); + if (quorum <= 0.0 || quorum > 1.0) { + quorum = DEFAULT_QUORUM; + } + weights = weights == null ? Map.of() : Map.copyOf(weights); + tiePolicy = tiePolicy == null ? TiePolicy.NO_DECISION : tiePolicy; + } + + /** + * All defaults: MAJORITY, LAST_SYNTHESIS, quorum 0.5, unweighted, NO_DECISION. + */ + public VoteConfig() { + this(VoteMethod.MAJORITY, OptionsSource.LAST_SYNTHESIS, List.of(), DEFAULT_QUORUM, Map.of(), false, TiePolicy.NO_DECISION); + } + } + + /** How a {@link VoteConfig} counts ballots (I14). */ + public enum VoteMethod { + MAJORITY, APPROVAL + } + + /** Where a {@link VoteConfig}'s ballot options come from (I14). */ + public enum OptionsSource { + LAST_SYNTHESIS, EXPLICIT + } + + /** What resolves a tied or quorum-failed vote (I14). */ + public enum TiePolicy { + MODERATOR_DECIDES, HUMAN_DECIDES, NO_DECISION } /** @@ -323,7 +839,47 @@ public enum PhaseType { /** Task execution by assigned agents. */ EXECUTE, /** Verification of task results. */ - VERIFY + VERIFY, + /** + * Explicit ballots (I14). Save-time validation forces VOTE phases to + * {@code PARALLEL} + {@code ContextScope.NONE} — ballot independence is + * enforced structurally (the pre-fan-out snapshot plus the F4 peer-visibility + * matrix mean no ballot can see another cast this phase), not advised in a + * prompt. + */ + VOTE, + /** + * 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, + /** + * Post-discussion retrospective (I8): what worked, what failed, what to do + * differently. Parsed lessons land in team-owned group memory (synthetic owner + * {@code "group:"+groupId}) so they surface as {@code {properties.*}} in every + * member's later discussions — institutional knowledge that compounds + * run-over-run. + */ + RETRO + } + + /** + * 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 { @@ -566,12 +1122,18 @@ public record TaskDefinition( String description, String assignToRole, List dependsOn, - int priority) { + int priority, + AssignmentMode assignmentMode) { public TaskDefinition(String subject, String description) { this(subject, description, "ALL", List.of(), 0); } + /** Backward-compatible constructor without {@code assignmentMode} (I18). */ + public TaskDefinition(String subject, String description, String assignToRole, List dependsOn, int priority) { + this(subject, description, assignToRole, dependsOn, priority, null); + } + public TaskDefinition { Objects.requireNonNull(subject, "Task subject must not be null"); Objects.requireNonNull(description, "Task description must not be null"); @@ -584,6 +1146,22 @@ public TaskDefinition(String subject, String description) { } } + /** + * How a task finds its agent (I18, CNP-lite). {@code null} on a task falls back + * to {@code taskListConfig.assignmentMode()}, and to {@link #ROLE} when that + * too is unset — every config that predates I18 behaves exactly as before. + */ + public enum AssignmentMode { + /** The planner/config assigns by role or round-robin — today's behavior. */ + ROLE, + /** + * Contract-Net-lite: eligible members submit blind parallel bids and the + * highest confidence wins (deterministic tie-break by speaking order; no bids + * falls back to ROLE — a wave never stalls on an auction). + */ + BID + } + // --- Dynamic Agent Configuration --- /** diff --git a/src/main/java/ai/labs/eddi/configs/groups/model/DiscussionStylePresets.java b/src/main/java/ai/labs/eddi/configs/groups/model/DiscussionStylePresets.java index 349af9bea2..6787b2d0e7 100644 --- a/src/main/java/ai/labs/eddi/configs/groups/model/DiscussionStylePresets.java +++ b/src/main/java/ai/labs/eddi/configs/groups/model/DiscussionStylePresets.java @@ -272,6 +272,105 @@ private DiscussionStylePresets() { ] ```"""; + /** + * The ballot prompt (I14). The JSON contract line is what + * {@code VoteTallyEngine}'s three-tier parse reads; the "vote independently" + * line is honesty, not the mechanism — independence is enforced structurally + * (PARALLEL + NONE scope + the pre-fan-out snapshot), so a model ignoring the + * instruction still cannot see any ballot cast this phase. + */ + public static final String TEMPLATE_VOTE = """ + The group must decide: + "{question}" + + The options are: + {#for option in options} + - {option} + {/for} + + As {displayName}, vote independently — you cannot see anyone else's ballot. + Respond with ONLY this JSON, no other text: + {ballotContract}"""; + + /** + * 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": ""|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."""; + + /** + * The retrospective prompt (I8). The JSON contract line is what + * {@code RetroEngine}'s three-tier parse reads; the cap is quoted to the model + * AND enforced by the parser regardless. + */ + public static final String TEMPLATE_RETRO = """ + The discussion on the question below has concluded: + "{question}" + + Full transcript: + {#for entry in transcript} + [{entry.phaseName}] {entry.speaker}: "{entry.content}" + {/for} + + Review how this group worked. What worked, what failed, and what should + this group do differently next time? Distill LESSONS the group should + remember for future discussions — durable, actionable, not a summary of + this question's answer. + + Respond with ONLY this JSON, no other text (max {maxLessonsPerRun} lessons): + {"lessons": [{"lesson": "", "context": ""}]}"""; + // Template lookup by phase type private static final Map DEFAULT_TEMPLATES = Map.ofEntries( Map.entry(PhaseType.OPINION, TEMPLATE_OPINION_INDEPENDENT), @@ -284,7 +383,11 @@ 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.VOTE, TEMPLATE_VOTE), + Map.entry(PhaseType.PROPOSAL, TEMPLATE_PROPOSAL), + Map.entry(PhaseType.BARGAIN, TEMPLATE_BARGAIN), + Map.entry(PhaseType.RETRO, TEMPLATE_RETRO)); /** * Returns the default template for a given phase type. @@ -318,10 +421,37 @@ public static List 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 negotiation(int rounds) { + List 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 roundTable(int rounds) { diff --git a/src/main/java/ai/labs/eddi/configs/groups/model/GroupConversation.java b/src/main/java/ai/labs/eddi/configs/groups/model/GroupConversation.java index 7e25568aff..56ca274ad1 100644 --- a/src/main/java/ai/labs/eddi/configs/groups/model/GroupConversation.java +++ b/src/main/java/ai/labs/eddi/configs/groups/model/GroupConversation.java @@ -14,10 +14,12 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; import java.util.Map; +import java.util.Queue; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CopyOnWriteArrayList; /** * Transcript record for a group conversation. Persisted with a single-version @@ -30,8 +32,34 @@ public class GroupConversation { * Current document shape this code understands (Wave 0, F6). Bump whenever a * Wave adds a field resume-time logic depends on, and register that version's * migration in {@code GroupConversationSchemaMigrations}. + *

+ * Version 4 — the 2026-08 group-features release shape, carrying BOTH + * resume-critical additions that ship together: {@code runtimePhases} (I12 — a + * facilitator-diverged phase list a resume must honor; an older pod would + * mis-index every bookmark into the config's list) and {@code negotiationState} + * (I11 — an older pod re-saving a paused document would drop the proposals, + * signed acceptances and concession ledger). No migration entry for either: v3 + * documents have neither field and Jackson defaults them correctly (identity + * hop). */ - public static final int CURRENT_SCHEMA_VERSION = 3; + // v4 (this release): the release shape — adds I11's negotiationState, I12's + // runtimePhases and I6's pausedRepeatSliceBase, all resume-consumed. No + // migration entries needed: Jackson defaults each on legacy documents to its + // pre-v4 behavior (null / null / -1). + public static final int CURRENT_SCHEMA_VERSION = 4; + /** + * The version a stored document claims when its JSON carries no + * {@code schemaVersion} key — i.e. it was written before F6 existed, which is + * every pre-F6 production document. Jackson runs the no-arg constructor and + * leaves the field initialiser standing either way, so the initialiser cannot + * distinguish "absent" from "current": it MUST be this legacy floor, and the + * single creation point ({@code GroupConversationService}) stamps + * {@link #CURRENT_SCHEMA_VERSION} explicitly. Initialising the field to CURRENT + * instead made key-less documents claim the current version and + * {@code prepareForResume}'s ladder run zero iterations on exactly the + * documents it exists for. + */ + public static final int LEGACY_SCHEMA_VERSION = 1; /** * The shape this specific document was last written in. Checked before a * resume: newer than {@link #CURRENT_SCHEMA_VERSION} refuses (this deployment @@ -41,7 +69,7 @@ public class GroupConversation { * per-resume config-drift check (bookmarked phase vs. current config) that * guards a different axis. See {@code GroupConversationSchemaMigrations}. */ - private int schemaVersion = CURRENT_SCHEMA_VERSION; + private int schemaVersion = LEGACY_SCHEMA_VERSION; private String id; private String groupId; private String userId; @@ -70,14 +98,18 @@ public class GroupConversation { // turns a successful discussion into a FAILED one. private Map memberDisplayNames = new ConcurrentHashMap<>(); /** - * Per-member dollar-cost attribution (Wave 0, F5): agentId → that member's cost - * so far. For an individual agent, its own private conversation's cumulative - * tracked cost; for a {@code MemberType.GROUP} member, the child discussion's - * own {@link #totalCost} rolled up whole. See {@code GroupCostLedger}'s Javadoc - * for the known gap this reflects (V1: a non-cascade member turn's own - * model-completion cost is not tracked anywhere yet — I1 closes that). - * Thread-safe: PARALLEL phases accumulate from multiple member turns - * concurrently. + * Per-conversation dollar-cost attribution (Wave 0, F5): attribution key → that + * conversation's cumulative tracked cost. The invariant is one key = one + * conversation (recording is by replacement, which is only idempotent under + * that reading), so keys are NOT uniformly agent ids: an ordinary member's key + * is its agentId, a separate-conversation turn uses its conversation key (I2's + * {@code __convergence_judge}, I4's {@code __dissent__*}), and a nested GROUP + * member's children are keyed {@code agentId:childConversationId} — each turn + * of a GROUP member is a fresh child discussion, and a per-agent key would + * replace child N−1's whole spend with child N's. Consumers wanting a member's + * total must sum matching keys; {@link #totalCost} is always the sum of + * everything. Thread-safe: PARALLEL phases accumulate from multiple member + * turns concurrently. */ private Map memberCosts = new ConcurrentHashMap<>(); /** @@ -104,6 +136,12 @@ public class GroupConversation { * one of those features ran. */ private DecisionRecord decision; + /** + * The negotiation's proposals + concession ledger (I11). {@code null} until the + * first PROPOSAL/BARGAIN phase produces state; persisted with the document so a + * pause/resume keeps the table as it stood. + */ + private NegotiationState negotiation; private int depth; /** Current discussion round (1-based). Incremented by continueDiscussion(). */ private int round = 1; @@ -132,6 +170,140 @@ public int getRoundStartTranscriptIndex() { public void setRoundStartTranscriptIndex(int roundStartTranscriptIndex) { this.roundStartTranscriptIndex = roundStartTranscriptIndex; } + + /** + * The phase list the CURRENT round actually runs (I12), persisted only once a + * facilitator move diverges it from the config's phases — a CALL_VOTE insertion + * or an EXTEND_PHASE repeat bump. {@code null} means the config is + * authoritative, which is every discussion without a facilitator intervention. + *

+ * Load-bearing across a pause: every resume path (approval, human turn, crash + * recovery) must execute and drift-check against THIS list when it is set — + * resolving the config's phases instead would mis-index the bookmark into a + * list the pause was not taken against. Schema version 4 guards exactly that + * (see {@link #CURRENT_SCHEMA_VERSION}). Cleared when a round completes: a + * facilitator's insertions are one-off by design, so a continuation round + * starts from the config again. + */ + private List runtimePhases; + + /** + * Executed non-CONTINUE facilitator moves so far (I12) — the counter + * {@code FacilitatorConfig.maxMovesPerDiscussion} caps. Persisted so a + * pause/resume cannot refill the budget. Rejected attempts never increment it. + */ + private int facilitatorMoveCount; + + /** + * EXTEND_PHASE count per phase index (I12), capped at 2 per phase. Keyed by the + * phase's index rendered as a string (document maps key by string). Safe under + * CALL_VOTE insertions: insertions land at {@code currentPhase + 1}, so an + * index that already carries a count (current or earlier) never shifts, and + * later phases have no counts yet. Cleared with {@link #runtimePhases} when the + * round completes. + */ + private Map facilitatorExtensions = new ConcurrentHashMap<>(); + + public List getRuntimePhases() { + return runtimePhases; + } + + public void setRuntimePhases(List runtimePhases) { + this.runtimePhases = runtimePhases; + } + + public int getFacilitatorMoveCount() { + return facilitatorMoveCount; + } + + public void setFacilitatorMoveCount(int facilitatorMoveCount) { + this.facilitatorMoveCount = facilitatorMoveCount; + } + + /** + * Read-only view — mutation goes through {@link #recordFacilitatorExtension} + * and {@link #clearFacilitatorExtensions}, so the caps cannot be edited behind + * the conversation's back. + */ + public Map getFacilitatorExtensions() { + return Collections.unmodifiableMap(facilitatorExtensions); + } + + /** EXTEND_PHASE count for one phase index (I12). */ + public int facilitatorExtensionCount(int phaseIdx) { + return facilitatorExtensions.getOrDefault(String.valueOf(phaseIdx), 0); + } + + public void recordFacilitatorExtension(int phaseIdx) { + facilitatorExtensions.merge(String.valueOf(phaseIdx), 1, Integer::sum); + } + + public void clearFacilitatorExtensions() { + facilitatorExtensions.clear(); + } + + public void setFacilitatorExtensions(Map facilitatorExtensions) { + this.facilitatorExtensions = facilitatorExtensions != null + ? new ConcurrentHashMap<>(facilitatorExtensions) + : new ConcurrentHashMap<>(); + } + + /** + * Rolling summary of transcript entries {@code [0, summaryUpToIndex)} for + * FULL-scope windowed rendering (I9), extended incrementally at phase + * boundaries by {@code GroupContextBuilder.updateWindowSummary}. A derived view + * only — the transcript itself is never modified. {@code null} until windowing + * first summarizes; legacy documents deserialize to exactly that state and + * simply start summarizing at the next boundary, so this is additive and needs + * no {@code CURRENT_SCHEMA_VERSION} bump. + */ + private String transcriptSummary; + /** Exclusive raw-transcript index {@link #transcriptSummary} covers through. */ + private int summaryUpToIndex; + /** + * The ANONYMOUS-scope twin of {@link #transcriptSummary}, built from + * "Anonymous"-labelled input so an ANONYMOUS phase's summary can never leak + * attribution (no de-anonymization). Kept separately rather than reusing the + * FULL summary, which contains real speaker names. + */ + private String anonymousTranscriptSummary; + /** + * Exclusive raw-transcript index {@link #anonymousTranscriptSummary} covers + * through. + */ + private int anonymousSummaryUpToIndex; + + public String getTranscriptSummary() { + return transcriptSummary; + } + + public void setTranscriptSummary(String transcriptSummary) { + this.transcriptSummary = transcriptSummary; + } + + public int getSummaryUpToIndex() { + return summaryUpToIndex; + } + + public void setSummaryUpToIndex(int summaryUpToIndex) { + this.summaryUpToIndex = summaryUpToIndex; + } + + public String getAnonymousTranscriptSummary() { + return anonymousTranscriptSummary; + } + + public void setAnonymousTranscriptSummary(String anonymousTranscriptSummary) { + this.anonymousTranscriptSummary = anonymousTranscriptSummary; + } + + public int getAnonymousSummaryUpToIndex() { + return anonymousSummaryUpToIndex; + } + + public void setAnonymousSummaryUpToIndex(int anonymousSummaryUpToIndex) { + this.anonymousSummaryUpToIndex = anonymousSummaryUpToIndex; + } private SharedTaskList taskList; /** Agents dynamically added during the discussion (recruited or created). */ private List dynamicMembers = new CopyOnWriteArrayList<>(); @@ -162,6 +334,24 @@ public void setRoundStartTranscriptIndex(int roundStartTranscriptIndex) { private Set retainedAgentIds = ConcurrentHashMap.newKeySet(); private int pausedAtPhaseIndex = -1; private int pausedTurnCount = 0; + /** + * Transcript index where the PAUSED repeat's entries begin (I6), or {@code -1}. + * A human turn pauses MID-repeat, after other speakers already appended this + * repeat's entries — the resumed leg recomputing "size at top of repeat" would + * slice only what came AFTER the pause, so the convergence check (and every + * later consumer of the repeat slice) silently loses the pre-pause + * contributions. Persisted with the pause, consumed exactly once with the + * speaker bookmark. {@code -1} on legacy documents keeps the old recompute. + */ + private int pausedRepeatSliceBase = -1; + + public int getPausedRepeatSliceBase() { + return pausedRepeatSliceBase; + } + + public void setPausedRepeatSliceBase(int pausedRepeatSliceBase) { + this.pausedRepeatSliceBase = pausedRepeatSliceBase; + } private String pausedPhaseName; private Instant pausedAt; private HitlPauseType hitlPauseType; @@ -169,13 +359,18 @@ public void setRoundStartTranscriptIndex(int roundStartTranscriptIndex) { private String hitlPauseReason; /** * Where inside a SEQUENTIAL phase's speaker list a pause landed (Wave 0, F2). - * {@code null} for every pause today — {@code PHASE} and {@code TASK} pauses - * (the only kinds that exist) both land at a phase/task boundary, never - * mid-speaker-list. Exists for I6 (human as a group member), which pauses - * between one speaker and the next within a running SEQUENTIAL phase; see - * {@link ResumePoint}'s own Javadoc for why PARALLEL phases never set this. + * {@code null} for {@code PHASE} and {@code TASK} pauses, which land at a + * phase/task boundary, never mid-speaker-list. I6's HUMAN_TURN pauses are the + * producer: they pause ON a specific speaker within a running phase; see + * {@link ResumePoint}'s own Javadoc (and its {@code HUMAN_TURN_PARALLEL} + * carve-out) for the resume semantics. */ private ResumePoint resumePoint; + /** + * The human member's turn an {@code AWAITING_HUMAN_INPUT} pause is waiting on + * (I6). Non-null exactly while the state is AWAITING_HUMAN_INPUT. + */ + private PendingHumanInput pendingHumanInput; /** Timeout policy copied from config at pause time (Phase 6d). */ private HitlTimeoutPolicy hitlTimeoutPolicy; /** @@ -226,15 +421,112 @@ public void setRoundStartTranscriptIndex(int roundStartTranscriptIndex) { private transient AgentGroupConfiguration.ProtocolConfig.CostPolicy costCeilingOutcome; /** - * A parent discussion's remaining cost budget at the moment it dispatched this - * nested one (I1), or {@code null} for a top-level discussion (and for a parent - * that has no ceiling of its own). The discussion runs under - * {@code min(own configured ceiling, this)} — see - * {@code GroupConversationService.effectiveCostCeiling}. Transient: it is a - * property of one dispatch, not of the stored discussion. + * One accepted artifact write (I17), queued by the artifact tools on the LIVE + * instance and drained by {@code MemberTurnExecutor} after the turn to fire the + * {@code artifact_updated} event. This indirection exists because tools have no + * listener reference — {@code ToolAssemblyContext} carries none — while the + * turn executor does. Transient and concurrent: PARALLEL phases run members + * (and so their tools) concurrently. */ + public record ArtifactChange(String artifactId, String name, String type, long version, String editorAgentId, + String status, boolean created) { + } + @JsonIgnore - private transient Double inheritedCostCeiling; + private final transient Queue pendingArtifactChanges = new ConcurrentLinkedQueue<>(); + + /** + * Serializes the drain HANDOFF over {@link #pendingArtifactChanges}. The queue + * itself is safe, but two PARALLEL turns ending together would split it between + * their drains and could then publish v2's event before v1's. The mutex is held + * only around the drain and the publisher flag — never across the listener + * callbacks, so a slow SSE client cannot block other turns' end-of-turn drains. + * See {@code MemberTurnExecutor#announceArtifactChanges}. + */ + @JsonIgnore + private final transient Object artifactAnnounceMutex = new Object(); + + /** The monitor {@code MemberTurnExecutor} serializes announce passes on. */ + @JsonIgnore + public Object artifactAnnounceMutex() { + return artifactAnnounceMutex; + } + + /** + * Whether a thread is currently PUBLISHING drained artifact changes. Guarded by + * {@link #artifactAnnounceMutex} (never read or written outside it) — this flag + * is what lets the mutex be released during the listener callbacks themselves: + * the active publisher keeps looping over late arrivals, and every other thread + * hands off and leaves instead of blocking on a slow SSE client. Deliberately + * not {@code isX}-named: runtime coordination state, invisible to Jackson. + */ + private transient boolean artifactAnnouncePublishing; + + @JsonIgnore + public boolean artifactAnnouncePublishing() { + return artifactAnnouncePublishing; + } + + @JsonIgnore + public void artifactAnnouncePublishing(boolean publishing) { + this.artifactAnnouncePublishing = publishing; + } + + /** Queues an accepted artifact write for the turn executor to announce. */ + @JsonIgnore + public void queueArtifactChange(ArtifactChange change) { + if (change != null) { + pendingArtifactChanges.add(change); + } + } + + /** Drains queued artifact writes — each drained exactly once. */ + @JsonIgnore + public List drainArtifactChanges() { + List drained = new ArrayList<>(); + ArtifactChange change; + while ((change = pendingArtifactChanges.poll()) != null) { + drained.add(change); + } + return drained; + } + + /** + * The discussion's shared artifacts (I17), populated at READ time by + * {@code GroupConversationService.readGroupConversation} from the artifact + * store — never persisted with this document (artifacts have their own + * collection; see {@link SharedArtifact}). Serialized when populated so REST's + * status payload and MCP's {@code read_group_conversation} both carry it; + * {@code READ_ONLY} because a stored copy must never be trusted back. Mirrors + * the {@code availableActions} idiom. + */ + @JsonIgnore + private transient List artifacts; + + @JsonProperty(value = "artifacts", access = JsonProperty.Access.READ_ONLY) + public List getArtifacts() { + return artifacts; + } + + @JsonIgnore + public void setArtifacts(List artifacts) { + this.artifacts = artifacts; + } + + /** + * A parent discussion's remaining cost budget at the moment it dispatched this + * nested one (I1), or a cadence run's {@code maxCostPerRun} (I13), or + * {@code null} for an unconstrained top-level discussion. The discussion runs + * under {@code min(own configured ceiling, this)} — see + * {@code GroupConversationService.effectiveCostCeiling}. + *

+ * PERSISTED (final-review finding): this was transient, so every HITL + * pause/resume re-read the document and silently DROPPED the ceiling — a + * cadence discussion whose group config had no ceiling of its own resumed with + * unlimited spend after one human approval. The ceiling is a property of the + * RUN, and the run survives pauses. + */ + private Double inheritedCostCeiling; /** * A single entry in the discussion transcript. Each entry records one agent's @@ -390,13 +682,157 @@ public enum TranscriptEntryType { * pause landed on; on resume, speakers before this index are skipped * rather than re-run * @param pauseKind - * free-text tag for observability (REST/MCP status payloads) — not - * consulted by any resume logic, which keys off this record's mere - * presence rather than what kind of mid-phase pause it names + * which kind of mid-phase pause this bookmark records. For most + * kinds it is a pure observability tag (resume logic keys off this + * record's mere presence), with ONE exception: + * {@link #RESUME_KIND_HUMAN_TURN_PARALLEL} tells the resumed leg + * that {@code speakerIdx} indexes the phase's HUMAN-only sublist and + * that the agent fan-out already ran — the parallel executor then + * skips straight to the remaining human turns instead of re-running + * the fan-out (the sole carve-out from the "PARALLEL never honors a + * bookmark" rule above; see I6) */ public record ResumePoint(int phaseIdx, int repeatIdx, int speakerIdx, String pauseKind) { } + /** {@link ResumePoint#pauseKind()} of a sequential HUMAN member turn (I6). */ + public static final String RESUME_KIND_HUMAN_TURN = "HUMAN_TURN"; + + /** + * {@link ResumePoint#pauseKind()} of a HUMAN turn in a PARALLEL phase (I6): + * {@code speakerIdx} indexes the phase's human-only sublist, and the resumed + * leg must NOT re-run the agent fan-out. + */ + public static final String RESUME_KIND_HUMAN_TURN_PARALLEL = "HUMAN_TURN_PARALLEL"; + + /** + * The one turn a paused {@code AWAITING_HUMAN_INPUT} discussion is waiting on + * (I6). Persisted with the document so the prompt survives a restart and the + * approval/inbox surfaces can display exactly what the member was asked. + * + * @param memberId + * the HUMAN member's {@code agentId} — the human principal id; + * submissions must come from this principal (or an admin) + * @param displayName + * the member's display name, for transcript attribution and UI + * @param phaseIdx + * phase the turn belongs to (matches the {@link ResumePoint}) + * @param repeatIdx + * repeat of that phase + * @param speakerIdx + * the member's index — into the phase's resolved speaker list for + * sequential turns, into the human-only sublist for parallel ones + * @param entryType + * {@link TranscriptEntryType} name the submitted content is recorded + * as — captured at pause time so a config edit while paused cannot + * re-type the entry (a human OPINION is an OPINION) + * @param renderedPrompt + * the phase input rendered for this member exactly as an agent would + * have received it — what the UI shows the human + * @param onTimeout + * the group's {@code humanMemberConfig.onTimeout} policy name at + * pause time (SKIP_TURN or ABORT) — bookmarked so crash recovery + * re-arms the same policy the pause promised, config edits + * notwithstanding + * @param requestedAt + * when the turn was requested + */ + public record PendingHumanInput(String memberId, String displayName, int phaseIdx, int repeatIdx, int speakerIdx, + String entryType, String renderedPrompt, String onTimeout, Instant requestedAt) { + } + + /** + * The negotiation's working state (I11): open/superseded proposals and the + * concession ledger. Persisted with the document — the ledger is the record, + * quoted back into every bargaining turn (accountability is the anti-sycophancy + * mechanism) and into the outcome. + *

+ * Lists are copy-on-write for the same reason the roster lists are: Jackson + * walks them unguarded on every persist while a turn may append. + */ + public static class NegotiationState { + + private List proposals = new CopyOnWriteArrayList<>(); + private List concessions = new CopyOnWriteArrayList<>(); + + /** + * Read-only view — all mutation goes through {@link #addProposal}, + * {@link #replaceProposal} and {@link #addConcession}, so the table cannot be + * edited behind the state's back. + */ + public List getProposals() { + return Collections.unmodifiableList(proposals); + } + + public void setProposals(List proposals) { + this.proposals = proposals != null ? new CopyOnWriteArrayList<>(proposals) : new CopyOnWriteArrayList<>(); + } + + /** Read-only view — see {@link #getProposals}. */ + public List getConcessions() { + return Collections.unmodifiableList(concessions); + } + + public void setConcessions(List concessions) { + this.concessions = concessions != null ? new CopyOnWriteArrayList<>(concessions) : new CopyOnWriteArrayList<>(); + } + + public void addProposal(Proposal proposal) { + proposals.add(proposal); + } + + /** In-place status/signature transition; a no-op if {@code oldOne} is gone. */ + public void replaceProposal(Proposal oldOne, Proposal newOne) { + int idx = proposals.indexOf(oldOne); + if (idx >= 0) { + proposals.set(idx, newOne); + } + } + + public void addConcession(Concession concession) { + concessions.add(concession); + } + } + + /** + * One proposal on the negotiation table (I11). + * + * @param id + * stable id ("p1", "p2", …) — what a BARGAIN turn's {@code accept} + * names + * @param byAgentId + * the proposer (implicitly the first acceptor of their own terms) + * @param round + * the phase repeat the proposal was made in + * @param terms + * the proposal's terms — a String in v1, deliberately untyped + * @param status + * OPEN, SUPERSEDED (the proposer made a newer proposal) or REJECTED + * @param acceptedBy + * agent ids that accepted these terms + * @param acceptanceEntryIndices + * agentId → absolute transcript index of that agent's accepting + * entry. The signed transcript entries ARE the co-signatures — no + * new crypto; these indices are what the AGREEMENT decision quotes + * in {@code tally.signedAcceptances} + */ + public record Proposal(String id, String byAgentId, int round, String terms, String status, + List acceptedBy, Map acceptanceEntryIndices) { + } + + /** Proposal status: still on the table. */ + public static final String PROPOSAL_OPEN = "OPEN"; + /** Proposal status: replaced by the proposer's own newer proposal. */ + public static final String PROPOSAL_SUPERSEDED = "SUPERSEDED"; + + /** + * One ledger line (I11): what an agent gave up, and what they received in + * return — every concession must name its counterpart; that structure is what + * stops sycophantic instant-agreement. + */ + public record Concession(String byAgentId, int round, String gaveUp, String inReturnFor, String refProposalId) { + } + /** * What kind of conclusion a {@link DecisionRecord} represents (Wave 0, F3). */ @@ -480,6 +916,14 @@ public enum GroupConversationState { CANCELLED, /** Paused for human approval — HITL foundation (Phase 9b). */ AWAITING_APPROVAL, + /** + * Paused because a HUMAN group member's turn is up (I6). Deliberately NOT + * {@link #AWAITING_APPROVAL}: approval endpoints must never accept free text, + * and an inbox must be able to tell "approve/reject this" from "you're up". + * Resolved only by {@code submitHumanInput} (or its timeout policy) — never by + * the approve/resume surface. + */ + AWAITING_HUMAN_INPUT, /** * Terminal — member conversations ended, ephemeral agents cleaned up, no * further follow-ups. @@ -488,7 +932,12 @@ public enum GroupConversationState { } public enum HitlPauseType { - PHASE, TASK + PHASE, TASK, + /** + * A HUMAN group member's turn (I6) — see + * {@link GroupConversationState#AWAITING_HUMAN_INPUT}. + */ + HUMAN_TURN } // --- Getters/Setters --- @@ -617,6 +1066,23 @@ public void setDecision(DecisionRecord decision) { this.decision = decision; } + public NegotiationState getNegotiation() { + return negotiation; + } + + public void setNegotiation(NegotiationState negotiation) { + this.negotiation = negotiation; + } + + /** The negotiation state, created on first use (I11). */ + @JsonIgnore + public NegotiationState negotiationState() { + if (negotiation == null) { + negotiation = new NegotiationState(); + } + return negotiation; + } + public int getDepth() { return depth; } @@ -752,6 +1218,9 @@ public List getAvailableActions() { // FAILED and CANCELLED are terminal but closeable — close ends member // conversations and reclaims ephemeral agents. case FAILED, CANCELLED -> List.of("close"); + // I6: the one state a human member acts on — the UI switches to an + // input prompt instead of approve/reject buttons. + case AWAITING_HUMAN_INPUT -> List.of("submitHumanInput"); case IN_PROGRESS, SYNTHESIZING, CREATED, AWAITING_APPROVAL -> List.of(); case CLOSED -> List.of(); }; @@ -827,6 +1296,14 @@ public void setResumePoint(ResumePoint resumePoint) { this.resumePoint = resumePoint; } + public PendingHumanInput getPendingHumanInput() { + return pendingHumanInput; + } + + public void setPendingHumanInput(PendingHumanInput pendingHumanInput) { + this.pendingHumanInput = pendingHumanInput; + } + public AgentGroupConfiguration.ProtocolConfig.CostPolicy getCostCeilingOutcome() { return costCeilingOutcome; } diff --git a/src/main/java/ai/labs/eddi/configs/groups/model/GroupWorkspace.java b/src/main/java/ai/labs/eddi/configs/groups/model/GroupWorkspace.java new file mode 100644 index 0000000000..d266f8a2e3 --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/groups/model/GroupWorkspace.java @@ -0,0 +1,313 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.configs.groups.model; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * A group's standing workspace (I13): the persistent half of a team. A group + * conversation is an episode; the workspace is what survives between + * episodes — the backlog, the cadences that pull work from it, and the team's + * running metrics. + *

+ * One document per group config id, in its own collection — deliberately NOT + * embedded in the versioned {@link AgentGroupConfiguration}: the workspace + * mutates on every cadence run and backlog write, and versioning it alongside + * the config would turn each of those into a config revision. + *

+ * Concurrency. Cadence fires are cluster-wide (any pod's poller may + * claim one), so "is a cadence discussion already running?" is decided by a + * conditional store write on {@link #runningDiscussionId} — see + * {@code IGroupWorkspaceStore#claimRun} — never by in-JVM locks. The idle value + * is the empty string, not {@code null}, because the conditional write compares + * a stored field against a concrete value. + *

+ * Per-member stats are reliability RECORDING only (research-adopted + * substrate): nothing routes or weights on them in v1, and that is a deliberate + * scope cut, not an oversight. + * + * @author ginccc + */ +public class GroupWorkspace { + + /** + * Document shape version for THIS collection (independent of + * {@code GroupConversation}'s). Version 1 is the first that ever existed. + */ + public static final int CURRENT_SCHEMA_VERSION = 1; + + /** + * Backlog ceiling. A backlog is a working set, not an archive — past this size, + * adding tasks fails with an actionable error instead of growing unboundedly + * (an LLM or a runaway integration can call add in a loop). + */ + public static final int MAX_BACKLOG_SIZE = 200; + + /** Default number of backlog tasks one cadence run pulls. */ + public static final int DEFAULT_MAX_BACKLOG_TASKS_PER_RUN = 5; + + /** {@link #runningDiscussionId}'s idle value — see the class Javadoc. */ + public static final String NO_RUNNING_DISCUSSION = ""; + + private String id; + private int schemaVersion = CURRENT_SCHEMA_VERSION; + private String groupId; + /** + * The team's persistent backlog. Reuses {@link SharedTaskList} whole — + * statuses, dependency plumbing, caps — so a cadence run can hand pulled tasks + * straight to the task-force machinery without a parallel task model. + */ + private SharedTaskList backlog = new SharedTaskList(); + private WorkspaceMetrics metrics = new WorkspaceMetrics(); + private List cadences = new CopyOnWriteArrayList<>(); + /** + * The cadence discussion currently in flight, or + * {@link #NO_RUNNING_DISCUSSION}. Claimed by conditional write before a cadence + * run starts; cleared by writeback once that discussion reaches a terminal + * state. + */ + private String runningDiscussionId = NO_RUNNING_DISCUSSION; + /** + * Backlog task ids the running discussion pulled — what writeback returns to + * PENDING if the discussion fails, and matches outcomes against when it + * completes. + */ + private List pulledTaskIds = new CopyOnWriteArrayList<>(); + private Instant created; + private Instant lastModified; + /** + * Optimistic-concurrency stamp for read-modify-write surfaces (review finding: + * two concurrent backlog adds could both pass the cap/duplicate checks against + * their own snapshots and the later whole-document write dropped the earlier + * task). Bumped by {@code IGroupWorkspaceStore.casRevision}; stored as a string + * because the conditional-store primitive compares string field equality. + * {@code null} on documents created before this field existed. + */ + private String revision = "0"; + + /** + * One scheduled pull from the backlog. + * + * @param cadenceId + * stable id within this workspace + * @param scheduleRef + * id of the {@code ScheduleConfiguration} driving this cadence — + * cadence execution rides {@code SchedulePollerService}'s + * cluster-aware claim/lease/retry machinery whole; the workspace + * never runs its own timer + * @param inputTemplate + * optional Qute template rendered over the backlog summary to + * produce the discussion question; {@code null} uses a plain default + * @param maxBacklogTasksPerRun + * how many executable tasks one fire pulls (default + * {@value #DEFAULT_MAX_BACKLOG_TASKS_PER_RUN}) + * @param maxCostPerRun + * dollar ceiling for one cadence discussion — dollar-primary per the + * Dream precedent; {@code null} falls back to the group's own + * {@code maxCostPerDiscussion} + * @param createdBy + * principal that created the cadence — the identity its discussions + * run under (a cadence run must be attributable to whoever set it + * up, not to a synthetic scheduler user) + */ + public record Cadence(String cadenceId, String scheduleRef, String inputTemplate, int maxBacklogTasksPerRun, + Double maxCostPerRun, String createdBy) { + + public Cadence { + if (maxBacklogTasksPerRun <= 0) { + maxBacklogTasksPerRun = DEFAULT_MAX_BACKLOG_TASKS_PER_RUN; + } + } + } + + /** + * The team's running totals. A plain mutable POJO — writeback is the only + * writer, and it runs under the workspace's single-writer claim. + */ + public static class WorkspaceMetrics { + + private int discussions; + private int tasksVerified; + private double totalCost; + private Instant lastRunAt; + private Map perMemberStats = new ConcurrentHashMap<>(); + + public int getDiscussions() { + return discussions; + } + + public void setDiscussions(int discussions) { + this.discussions = discussions; + } + + public int getTasksVerified() { + return tasksVerified; + } + + public void setTasksVerified(int tasksVerified) { + this.tasksVerified = tasksVerified; + } + + public double getTotalCost() { + return totalCost; + } + + public void setTotalCost(double totalCost) { + this.totalCost = totalCost; + } + + public Instant getLastRunAt() { + return lastRunAt; + } + + public void setLastRunAt(Instant lastRunAt) { + this.lastRunAt = lastRunAt; + } + + public Map getPerMemberStats() { + return perMemberStats; + } + + public void setPerMemberStats(Map perMemberStats) { + this.perMemberStats = perMemberStats != null + ? new ConcurrentHashMap<>(perMemberStats) + : new ConcurrentHashMap<>(); + } + } + + /** + * Reliability recording for one member — see the class Javadoc's scope note. + */ + public static class MemberStats { + + private int tasksVerified; + private int tasksFailed; + + public int getTasksVerified() { + return tasksVerified; + } + + public void setTasksVerified(int tasksVerified) { + this.tasksVerified = tasksVerified; + } + + public int getTasksFailed() { + return tasksFailed; + } + + public void setTasksFailed(int tasksFailed) { + this.tasksFailed = tasksFailed; + } + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public int getSchemaVersion() { + return schemaVersion; + } + + public void setSchemaVersion(int schemaVersion) { + this.schemaVersion = schemaVersion; + } + + public String getGroupId() { + return groupId; + } + + public void setGroupId(String groupId) { + this.groupId = groupId; + } + + public SharedTaskList getBacklog() { + return backlog; + } + + public void setBacklog(SharedTaskList backlog) { + this.backlog = backlog != null ? backlog : new SharedTaskList(); + } + + public WorkspaceMetrics getMetrics() { + return metrics; + } + + public void setMetrics(WorkspaceMetrics metrics) { + this.metrics = metrics != null ? metrics : new WorkspaceMetrics(); + } + + /** + * Read-only view — mutation goes through {@link #addCadence} and + * {@link #removeCadence}, so the schedule references cannot be edited behind + * the workspace's back. + */ + public List getCadences() { + return Collections.unmodifiableList(cadences); + } + + public void addCadence(Cadence cadence) { + if (cadence != null) { + cadences.add(cadence); + } + } + + /** @return {@code true} if a cadence with that id existed and was removed */ + public boolean removeCadence(String cadenceId) { + return cadences.removeIf(c -> c.cadenceId().equals(cadenceId)); + } + + public void setCadences(List cadences) { + this.cadences = cadences != null ? new CopyOnWriteArrayList<>(cadences) : new CopyOnWriteArrayList<>(); + } + + public String getRunningDiscussionId() { + return runningDiscussionId; + } + + public void setRunningDiscussionId(String runningDiscussionId) { + this.runningDiscussionId = runningDiscussionId != null ? runningDiscussionId : NO_RUNNING_DISCUSSION; + } + + public List getPulledTaskIds() { + return pulledTaskIds; + } + + public void setPulledTaskIds(List pulledTaskIds) { + this.pulledTaskIds = pulledTaskIds != null ? new CopyOnWriteArrayList<>(pulledTaskIds) : new CopyOnWriteArrayList<>(); + } + + public Instant getCreated() { + return created; + } + + public void setCreated(Instant created) { + this.created = created; + } + + public Instant getLastModified() { + return lastModified; + } + + public void setLastModified(Instant lastModified) { + this.lastModified = lastModified; + } + + public String getRevision() { + return revision; + } + + public void setRevision(String revision) { + this.revision = revision; + } +} diff --git a/src/main/java/ai/labs/eddi/configs/groups/model/SharedArtifact.java b/src/main/java/ai/labs/eddi/configs/groups/model/SharedArtifact.java new file mode 100644 index 0000000000..627b949943 --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/groups/model/SharedArtifact.java @@ -0,0 +1,207 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.configs.groups.model; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/** + * A typed document a group discussion's members create and co-edit through the + * artifact tools (I17, blackboard-lite) — state that lives outside the + * dialogue, so a structured thing an agent produces stops being prose the next + * agent re-parses. + *

+ * Persisted in its own collection ({@code sharedartifacts}), never + * embedded in the {@link GroupConversation} document: the discussion loop + * persists that document whole from stale snapshots after each phase, which + * would silently clobber concurrent artifact writes. Because artifacts have + * their own collection, tools write them through the store directly — the F1 + * live-instance rule that governs task-list writes does not apply here. + *

+ * Concurrency is deterministic compare-and-set on {@link #version} (see + * {@code ISharedArtifactStore.updateIfVersion}): a stale writer gets a "re-read + * and merge" rejection and retries — explicitly not an LLM fusion + * arbiter, whose failure mode is a silent bad merge rather than a retry. + * + * @author ginccc + */ +public class SharedArtifact { + + /** + * Hard ceiling on {@link #content}, in UTF-8 bytes. An LLM handed a write tool + * can write in a loop; the cap bounds a single document while staying far above + * any deliberated draft. + */ + public static final int MAX_CONTENT_BYTES = 256 * 1024; + + /** + * How many prior revisions ride on the document. A bounded tail, oldest dropped + * — full history is the transcript's job, this exists so a bad edit one or two + * turns back is recoverable without archaeology. + */ + public static final int HISTORY_CAP = 10; + + /** The artifact's content type — how consumers should read {@link #content}. */ + public enum ArtifactType { + TEXT, MARKDOWN, JSON + } + + /** Editing lifecycle: DRAFT while being worked, FINAL once frozen. */ + public enum ArtifactStatus { + DRAFT, FINAL + } + + /** + * One superseded revision. {@code version} is the version this content carried + * while current. + */ + public record ArtifactRevision(String content, String editorAgentId, long version, Instant at) { + } + + private String id; + private String groupConversationId; + /** + * The owning discussion's {@code userId}, stamped at creation so GDPR erasure + * can sweep artifacts by user exactly like group conversations — independent of + * whether the parent document still exists at erasure time. + */ + private String ownerUserId; + private String name; + private ArtifactType type; + private String content; + /** + * Monotonic edit counter, starting at 1 on creation — the CAS token every + * update must present. Serialized as a JSON number; the store's version CAS + * uses the numeric {@code storeIfFieldEquals} overload for exactly that reason. + */ + private long version; + private String lastEditorAgentId; + private ArtifactStatus status = ArtifactStatus.DRAFT; + private List history = new ArrayList<>(); + private Instant createdAt; + private Instant updatedAt; + + /** + * Applies an accepted edit: archives the current content into {@link #history} + * (capped, oldest dropped), then installs the new content and bumps + * {@link #version}. Pure in-memory mutation — persistence and the CAS happen at + * the store. + */ + public void applyEdit(String newContent, String editorAgentId, Instant at) { + history.add(new ArtifactRevision(content, lastEditorAgentId, version, updatedAt != null ? updatedAt : createdAt)); + while (history.size() > HISTORY_CAP) { + history.remove(0); + } + content = newContent; + lastEditorAgentId = editorAgentId; + version = version + 1; + updatedAt = at; + } + + /** + * UTF-8 byte length of {@code content}, for the {@link #MAX_CONTENT_BYTES} cap. + */ + public static int contentBytes(String content) { + return content == null ? 0 : content.getBytes(StandardCharsets.UTF_8).length; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getGroupConversationId() { + return groupConversationId; + } + + public void setGroupConversationId(String groupConversationId) { + this.groupConversationId = groupConversationId; + } + + public String getOwnerUserId() { + return ownerUserId; + } + + public void setOwnerUserId(String ownerUserId) { + this.ownerUserId = ownerUserId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public ArtifactType getType() { + return type; + } + + public void setType(ArtifactType type) { + this.type = type; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public long getVersion() { + return version; + } + + public void setVersion(long version) { + this.version = version; + } + + public String getLastEditorAgentId() { + return lastEditorAgentId; + } + + public void setLastEditorAgentId(String lastEditorAgentId) { + this.lastEditorAgentId = lastEditorAgentId; + } + + public ArtifactStatus getStatus() { + return status; + } + + public void setStatus(ArtifactStatus status) { + this.status = status; + } + + public List getHistory() { + return history; + } + + public void setHistory(List history) { + this.history = history != null ? history : new ArrayList<>(); + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/src/main/java/ai/labs/eddi/configs/groups/model/SharedTaskList.java b/src/main/java/ai/labs/eddi/configs/groups/model/SharedTaskList.java index 99c83dfc63..a9b0b0cbff 100644 --- a/src/main/java/ai/labs/eddi/configs/groups/model/SharedTaskList.java +++ b/src/main/java/ai/labs/eddi/configs/groups/model/SharedTaskList.java @@ -8,8 +8,10 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; /** * Runtime task list for task-oriented group conversations. Embedded in @@ -607,4 +609,41 @@ public synchronized List getTasks() { public synchronized void setTasks(List tasks) { this.tasks = tasks != null ? new ArrayList<>(tasks) : new ArrayList<>(); } + + // ==================== I18 — bid awards (CNP-lite) ==================== + + /** + * The winning bid a BID-mode task was awarded on (I18) — per-task metadata, + * deliberately not a global {@code DecisionRecord}: an award is a scheduling + * fact about one task, not the discussion's conclusion. + * + * @param agentId + * the winner + * @param confidence + * the winning self-assessed confidence (0..1, clamped at parse) + * @param estimatedComplexity + * the bidder's XS/S/M/L estimate, verbatim + * @param rationale + * why the bidder claimed the task — quoted by dashboards + */ + public record AwardedBid(String agentId, double confidence, String estimatedComplexity, String rationale) { + } + + /** taskId → the bid it was awarded on. Only BID-mode tasks ever appear here. */ + private Map awardedBids = new ConcurrentHashMap<>(); + + public Map getAwardedBids() { + return awardedBids; + } + + public void setAwardedBids(Map awardedBids) { + this.awardedBids = awardedBids != null ? new ConcurrentHashMap<>(awardedBids) : new ConcurrentHashMap<>(); + } + + /** Records the award a task's assignment came from (I18). */ + public synchronized void recordAwardedBid(String taskId, AwardedBid bid) { + if (taskId != null && bid != null) { + awardedBids.put(taskId, bid); + } + } } diff --git a/src/main/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStore.java b/src/main/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStore.java index 04ff7972be..b770870756 100644 --- a/src/main/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStore.java +++ b/src/main/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStore.java @@ -5,6 +5,7 @@ package ai.labs.eddi.configs.groups.mongo; import ai.labs.eddi.configs.hitl.HitlConfigValidation; +import ai.labs.eddi.configs.groups.ArtifactValidators; import ai.labs.eddi.configs.groups.IAgentGroupStore; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.DiscussionPhase; @@ -14,11 +15,16 @@ import ai.labs.eddi.datastore.IResourceStorageFactory; import ai.labs.eddi.datastore.IResourceStore; import ai.labs.eddi.datastore.serialization.IDocumentBuilder; +import ai.labs.eddi.utils.LogSanitizer; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.jboss.logging.Logger; +import java.time.Duration; +import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Objects; /** * DB-agnostic store for group configurations. Extends @@ -41,8 +47,13 @@ public AgentGroupStore(IResourceStorageFactory storageFactory, IDocumentBuilder public IResourceStore.IResourceId create(AgentGroupConfiguration groupConfiguration) throws IResourceStore.ResourceStoreException { HitlConfigValidation.validate(groupConfiguration.getHitlConfig()); + validateVotePhases(groupConfiguration); + validateHumanMembers(groupConfiguration); + validateFacilitator(groupConfiguration); + ArtifactValidators.requireValidSpecs(groupConfiguration.getArtifactConfig()); normalizeNonPositiveCostCeiling(groupConfiguration); warnOnModeratorlessPhases(groupConfiguration); + warnOnSummarizerlessWindow(groupConfiguration); return super.create(groupConfiguration); } @@ -52,11 +63,149 @@ public Integer update(String id, Integer version, AgentGroupConfiguration groupC throws IResourceStore.ResourceStoreException, IResourceStore.ResourceModifiedException, IResourceStore.ResourceNotFoundException { HitlConfigValidation.validate(groupConfiguration.getHitlConfig()); + validateVotePhases(groupConfiguration); + validateHumanMembers(groupConfiguration); + validateFacilitator(groupConfiguration); + ArtifactValidators.requireValidSpecs(groupConfiguration.getArtifactConfig()); normalizeNonPositiveCostCeiling(groupConfiguration); warnOnModeratorlessPhases(groupConfiguration); + warnOnSummarizerlessWindow(groupConfiguration); return super.update(id, version, groupConfiguration); } + /** + * I6 save-time matrix for HUMAN members. Hard rejections + * ({@link IllegalArgumentException}, {@code HitlConfigValidation}'s contract) + * are safe here in a way {@link #warnOnModeratorlessPhases} could not be: no + * pre-existing document can contain {@code MemberType.HUMAN}, so there is no + * legacy config a rejection could strand. + *

    + *
  • a HUMAN member must carry a {@code displayName} — a paused discussion + * must be able to say WHO it is waiting on;
  • + *
  • groups with HUMAN members must not run task-force phases + * (PLAN/EXECUTE/VERIFY assign work on agent-latency math and pause inside wave + * workers) nor {@code targetEachPeer} phases (a human on both axes of an + * N×(N-1) round would owe up to 2(N-1) pauses per repeat, and the flat speaker + * bookmark has no (speaker,target) coordinate) — preset-expanded like + * {@link #moderatorlessPhaseNames}, or the check is inert for preset-style + * groups;
  • + *
  • a group containing HUMAN members may not be USED as a nested GROUP member + * (one level deep here; {@code MemberTurnExecutor} carries the runtime backstop + * for configs edited afterwards);
  • + *
  • {@code humanMemberConfig.turnTimeout} must parse as an ISO-8601 + * duration;
  • + *
  • a HUMAN moderator is allowed but warned about — every synthesis then + * waits on a person.
  • + *
+ */ + void validateHumanMembers(AgentGroupConfiguration config) throws IResourceStore.ResourceStoreException { + List problems = humanMemberProblems(config); + if (!problems.isEmpty()) { + throw new IllegalArgumentException(String.join("; ", problems)); + } + // "members": null in the JSON reaches here as a literal null list — the + // pure helper already tolerates it; there is nothing human to validate. + List members = config.getMembers() != null ? config.getMembers() : List.of(); + // Nested check needs the store — kept out of the pure helper. + for (var member : members) { + if (member != null && member.memberType() == AgentGroupConfiguration.MemberType.GROUP) { + AgentGroupConfiguration child = readChildConfig(member.agentId()); + if (child != null && hasHumanMembers(child)) { + throw new IllegalArgumentException( + "members['" + member.agentId() + "'] is a nested group that contains HUMAN members — " + + "human turns cannot pause a nested discussion (I6 v1); remove the human from the " + + "child group or flatten the hierarchy"); + } + } + } + String moderator = config.getModeratorAgentId(); + if (moderator != null && members.stream() + .anyMatch(m -> m != null && m.memberType() == AgentGroupConfiguration.MemberType.HUMAN + && moderator.equals(m.agentId()))) { + LOGGER.warnf("Group '%s' names HUMAN member '%s' as moderator — every synthesis phase will pause and wait " + + "for their input", LogSanitizer.sanitize(config.getName()), LogSanitizer.sanitize(moderator)); + } + } + + /** + * The pure, assertable part of the I6 matrix (same split as + * {@link #moderatorlessPhaseNames}): every problem with this config's HUMAN + * members that needs no store access. Empty list = valid. + */ + public static List humanMemberProblems(AgentGroupConfiguration config) { + List humans = config.getMembers() == null + ? List.of() + : config.getMembers().stream() + .filter(m -> m != null && m.memberType() == AgentGroupConfiguration.MemberType.HUMAN) + .toList(); + List problems = new ArrayList<>(); + for (var human : humans) { + if (human.displayName() == null || human.displayName().isBlank()) { + problems.add("HUMAN member '" + human.agentId() + "' needs a displayName"); + } + if (human.agentId() == null || human.agentId().isBlank()) { + problems.add("a HUMAN member needs an agentId carrying the human's principal id"); + } + } + if (!humans.isEmpty()) { + // Preset-expanded, or the check is inert for preset-style groups. + List phases = config.getPhases(); + if (phases == null || phases.isEmpty()) { + DiscussionStyle style = config.getStyle() != null ? config.getStyle() : DiscussionStyle.ROUND_TABLE; + phases = DiscussionStylePresets.expand(style, config.getMaxRounds()); + } + boolean taskPhases = phases.stream().filter(Objects::nonNull).anyMatch( + p -> p.type() == AgentGroupConfiguration.PhaseType.PLAN + || p.type() == AgentGroupConfiguration.PhaseType.EXECUTE + || p.type() == AgentGroupConfiguration.PhaseType.VERIFY); + if (taskPhases) { + problems.add("HUMAN members cannot join task-force groups (PLAN/EXECUTE/VERIFY phases) — " + + "task waves assign and time work on agent latencies (I6 v1)"); + } + boolean peerPhases = phases.stream().filter(Objects::nonNull).anyMatch(DiscussionPhase::targetEachPeer); + if (peerPhases) { + problems.add("HUMAN members cannot join groups with targetEachPeer phases — a human would owe one " + + "authored critique per peer AND be a target, multiplying pauses (I6 v1)"); + } + } + var humanConfig = config.getHumanMemberConfig(); + if (humanConfig != null && humanConfig.turnTimeout() != null && !humanConfig.turnTimeout().isBlank()) { + try { + Duration parsed = Duration.parse(humanConfig.turnTimeout()); + // Duration.parse accepts PT0S and PT-4H; both would arm a timeout + // that fires effectively immediately (past-due clamps to the grace + // window), silently skipping every human turn. + if (parsed.isZero() || parsed.isNegative()) { + problems.add("humanMemberConfig.turnTimeout must be a positive duration, not '" + + humanConfig.turnTimeout() + "'"); + } + } catch (Exception e) { + problems.add("humanMemberConfig.turnTimeout must be an ISO-8601 duration (e.g. PT4H), not '" + + humanConfig.turnTimeout() + "'"); + } + } + return problems; + } + + /** True if the config lists at least one HUMAN member. */ + static boolean hasHumanMembers(AgentGroupConfiguration config) { + return config.getMembers() != null && config.getMembers().stream() + .anyMatch(m -> m != null && m.memberType() == AgentGroupConfiguration.MemberType.HUMAN); + } + + /** Latest version of a (possible) child group config, or null if unreadable. */ + private AgentGroupConfiguration readChildConfig(String groupId) { + try { + IResourceStore.IResourceId resId = getCurrentResourceId(groupId); + return resId != null ? read(groupId, resId.getVersion()) : null; + } catch (Exception e) { + // Deployment-order tolerance: an unreadable/absent child cannot block + // the parent save; the runtime backstop covers it. + LOGGER.debugf("Nested-group human check could not read child '%s': %s", groupId, e.getMessage()); + return null; + } + } + /** * I3: a phase restricted to {@code participants: "MODERATOR"} in a group that * names no {@code moderatorAgentId} cannot run as written. The engine picks the @@ -103,6 +252,111 @@ static List moderatorlessPhaseNames(AgentGroupConfiguration groupConfigu .toList(); } + /** + * I14: VOTE phases are validated as HARD errors — the fields are new, so no + * stored config predating this release can trip them (same rationale as the + * cascade pricing validation). Ballot independence is the point of the PARALLEL + * + NONE requirement: enforced structurally at save time, not advised in a + * prompt at run time. + */ + public static void validateVotePhases(AgentGroupConfiguration groupConfiguration) { + List phases = groupConfiguration.getPhases(); + if (phases == null) { + return; + } + for (int i = 0; i < phases.size(); i++) { + DiscussionPhase phase = phases.get(i); + if (phase == null || phase.type() != AgentGroupConfiguration.PhaseType.VOTE) { + continue; + } + String path = "phases[" + i + "] (VOTE)"; + if (phase.turnOrder() != AgentGroupConfiguration.TurnOrder.PARALLEL) { + throw new IllegalArgumentException(path + " must use turnOrder PARALLEL — ballots are cast blind against the " + + "pre-fan-out snapshot; a sequential vote lets later ballots read earlier ones"); + } + if (phase.contextScope() != null && phase.contextScope() != AgentGroupConfiguration.ContextScope.NONE) { + throw new IllegalArgumentException(path + " must use contextScope NONE — ballot independence is enforced " + + "structurally, not advised in the prompt"); + } + if (phase.targetEachPeer()) { + throw new IllegalArgumentException(path + " must not use targetEachPeer"); + } + var voteConfig = phase.voteConfig(); + if (voteConfig == null) { + continue; + } + if (voteConfig.optionsSource() == AgentGroupConfiguration.OptionsSource.EXPLICIT && voteConfig.options().size() < 2) { + throw new IllegalArgumentException(path + " with optionsSource EXPLICIT needs at least 2 options"); + } + if (voteConfig.tiePolicy() == AgentGroupConfiguration.TiePolicy.HUMAN_DECIDES) { + throw new IllegalArgumentException(path + ".tiePolicy HUMAN_DECIDES needs human group members (I6), which are " + + "not available yet — use MODERATOR_DECIDES or NO_DECISION"); + } + for (Map.Entry weight : voteConfig.weights().entrySet()) { + // isFinite: NaN passes every < comparison and would poison the + // weighted totals; infinity would decide every vote alone. + if (weight.getValue() == null || !Double.isFinite(weight.getValue()) || weight.getValue() < 0) { + throw new IllegalArgumentException(path + ".weights['" + weight.getKey() + "'] must be finite and >= 0"); + } + } + } + } + + /** + * I12 save-time checks for {@code facilitator}. Hard rejections, same safety + * argument as {@link #validateVotePhases}: no legacy document can contain the + * new config block, so a throw can never brick an existing group. + *

+ * The END_PHASE/EXTEND_PHASE-with-EACH_PHASE combination is rejected rather + * than warned: both moves act on a phase's remaining repeats, and a + * phase-boundary checkpoint has none — every attempt would be rejected at + * runtime, which is a config that can only ever produce noise. + */ + public static void validateFacilitator(AgentGroupConfiguration config) { + var facilitator = config.getFacilitator(); + if (facilitator == null || !facilitator.enabled()) { + return; + } + if (facilitator.agentId() == null || facilitator.agentId().isBlank()) { + throw new IllegalArgumentException("facilitator.agentId is required when the facilitator is enabled"); + } + var moves = facilitator.allowedMoves(); + boolean midPhaseMoves = moves.contains(AgentGroupConfiguration.FacilitatorMove.END_PHASE) + || moves.contains(AgentGroupConfiguration.FacilitatorMove.EXTEND_PHASE); + if (midPhaseMoves && facilitator.checkAfter() == AgentGroupConfiguration.FacilitatorCheckpoint.EACH_PHASE) { + throw new IllegalArgumentException("facilitator.allowedMoves contains END_PHASE/EXTEND_PHASE, which act " + + "mid-phase — set checkAfter to EACH_REPEAT, or drop those moves"); + } + if (moves.contains(AgentGroupConfiguration.FacilitatorMove.ESCALATE_HUMAN) + && (facilitator.escalateTo() == null || facilitator.escalateTo().isBlank())) { + throw new IllegalArgumentException("facilitator.escalateTo is required when ESCALATE_HUMAN is an allowed move " + + "— an escalation must name the principal it waits on"); + } + if (facilitator.maxMovesPerDiscussion() > 100) { + throw new IllegalArgumentException("facilitator.maxMovesPerDiscussion must be at most 100 — the facilitator " + + "is a bounded intervention mechanism, not an orchestrator"); + } + } + + /** + * I9: a window that asks for summarization but names no summarizer model will + * silently degrade to the plain truncation marker at discussion time. Worth + * telling the author at save time, when they can still add + * {@code llmProvider}/{@code llmModel}. A warning, not a rejection — the + * truncation fallback is well-defined behaviour, same warn-not-reject shape as + * {@link #warnOnModeratorlessPhases}. + */ + private void warnOnSummarizerlessWindow(AgentGroupConfiguration groupConfiguration) { + var window = groupConfiguration.getContextWindow(); + if (window == null || !window.enabled() || !Boolean.TRUE.equals(window.summarizeOverflow())) { + return; + } + if (window.llmProvider() == null || window.llmModel() == null) { + LOGGER.warnf("Group '%s' enables contextWindow summarization but names no llmProvider/llmModel — " + + "overflow will fall back to a plain truncation marker", LogSanitizer.sanitize(groupConfiguration.getName())); + } + } + /** * I1: a {@code maxCostPerDiscussion} of zero or less would stop the very first * turn of every discussion this group ever runs — almost certainly a mistake (a diff --git a/src/main/java/ai/labs/eddi/configs/groups/mongo/GroupWorkspaceStore.java b/src/main/java/ai/labs/eddi/configs/groups/mongo/GroupWorkspaceStore.java new file mode 100644 index 0000000000..f7186c517c --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/groups/mongo/GroupWorkspaceStore.java @@ -0,0 +1,222 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.configs.groups.mongo; + +import ai.labs.eddi.configs.groups.IGroupWorkspaceStore; +import ai.labs.eddi.configs.groups.model.GroupWorkspace; +import ai.labs.eddi.datastore.IResourceFilter; +import ai.labs.eddi.datastore.IResourceStorage; +import ai.labs.eddi.datastore.IResourceStore; +import ai.labs.eddi.datastore.IResourceStorageFactory; +import ai.labs.eddi.datastore.serialization.IDocumentBuilder; +import ai.labs.eddi.utils.LogSanitizer; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.regex.Pattern; + +/** + * DB-agnostic store for {@link GroupWorkspace} documents (I13). Follows + * {@link GroupConversationStore}'s single-version pattern; workspaces are keyed + * logically by {@code groupId} (one document per group), physically by the + * storage id. + * + * @author ginccc + */ +@ApplicationScoped +public class GroupWorkspaceStore implements IGroupWorkspaceStore { + + private static final Logger LOGGER = Logger.getLogger(GroupWorkspaceStore.class); + private static final int SINGLE_VERSION = 1; + + /** Same reasoning as {@link GroupConversationStore}'s SAFE_ID. */ + private static final Pattern SAFE_ID = Pattern.compile("[A-Za-z0-9-]+"); + + private final IResourceStorage storage; + + @Inject + public GroupWorkspaceStore(IResourceStorageFactory storageFactory, IDocumentBuilder documentBuilder) { + this.storage = storageFactory.create("groupworkspaces", documentBuilder, GroupWorkspace.class, "groupId"); + } + + @Override + public GroupWorkspace find(String groupId) throws IResourceStore.ResourceStoreException { + if (groupId == null || groupId.isBlank() || !SAFE_ID.matcher(groupId).matches()) { + return null; + } + try { + List ids = findWorkspaceIds(groupId); + if (ids.isEmpty()) { + return null; + } + String survivorId = survivorId(ids); + if (ids.size() > 1) { + LOGGER.warnf("Group %s has %d workspace documents — a concurrent readOrCreate raced its " + + "duplicate guard; reading the deterministic survivor %s", + LogSanitizer.sanitize(groupId), ids.size(), survivorId); + } + IResourceStorage.IResource resource = storage.read(survivorId, SINGLE_VERSION); + if (resource == null) { + return null; + } + GroupWorkspace workspace = resource.getData(); + workspace.setId(survivorId); + return workspace; + } catch (IOException e) { + throw new IResourceStore.ResourceStoreException("Failed to read group workspace: " + e.getMessage(), e); + } + } + + @Override + public GroupWorkspace readOrCreate(String groupId) throws IResourceStore.ResourceStoreException { + GroupWorkspace existing = find(groupId); + if (existing != null) { + return existing; + } + var workspace = new GroupWorkspace(); + workspace.setGroupId(groupId); + workspace.setCreated(Instant.now()); + workspace.setLastModified(Instant.now()); + try { + IResourceStorage.IResource resource = storage.newResource(workspace); + storage.store(resource); + workspace.setId(resource.getId()); + } catch (IOException e) { + throw new IResourceStore.ResourceStoreException("Failed to create group workspace: " + e.getMessage(), e); + } + // Duplicate-insert guard (review finding): the storage abstraction has no + // unique constraint on groupId, so two concurrent creators can both miss + // find() above and insert. Re-query: every racer that does NOT hold the + // deterministic survivor removes ITS OWN insert and adopts the survivor — + // all callers converge on one document. find() picks the same survivor, + // so even a racer that crashes before this guard cannot split writes. + List ids = findWorkspaceIds(groupId); + if (ids.size() > 1) { + String survivorId = survivorId(ids); + if (!survivorId.equals(workspace.getId())) { + LOGGER.infof("readOrCreate raced for group %s — removing this caller's duplicate %s, adopting %s", + LogSanitizer.sanitize(groupId), workspace.getId(), survivorId); + storage.removeAllPermanently(workspace.getId()); + GroupWorkspace survivor = find(groupId); + if (survivor != null) { + return survivor; + } + } + } + return workspace; + } + + private List findWorkspaceIds(String groupId) { + var filter = new IResourceFilter.QueryFilters( + List.of(new IResourceFilter.QueryFilter("groupId", "^" + groupId + "$"))); + // High enough that EVERY realistic racer set fits in one query — with a + // small limit, three-plus concurrent creators could each see a different + // subset and compute different survivors, and the documents would never + // converge (review finding). + List ids = storage.findResources( + new IResourceFilter.QueryFilters[]{filter}, "lastModified", 0, 50); + return ids != null ? ids : List.of(); + } + + /** + * The lexicographically smallest id — for ObjectIds that is the EARLIEST + * insert, and every caller (both racers and later readers) derives the same + * answer with no coordination. + */ + private static String survivorId(List ids) { + return ids.stream().map(IResourceStore.IResourceId::getId).sorted().findFirst().orElseThrow(); + } + + @Override + public void update(GroupWorkspace workspace) throws IResourceStore.ResourceStoreException { + try { + workspace.setLastModified(Instant.now()); + IResourceStorage.IResource resource = storage.newResource(workspace.getId(), SINGLE_VERSION, workspace); + storage.store(resource); + } catch (IOException e) { + throw new IResourceStore.ResourceStoreException("Failed to update group workspace: " + e.getMessage(), e); + } + } + + @Override + public void deleteByGroupId(String groupId) throws IResourceStore.ResourceStoreException { + GroupWorkspace workspace = find(groupId); + if (workspace != null && workspace.getId() != null) { + storage.removeAllPermanently(workspace.getId()); + LOGGER.infof("Deleted workspace for group %s", LogSanitizer.sanitize(groupId)); + } + } + + @Override + public boolean casRevision(GroupWorkspace workspace) throws IResourceStore.ResourceStoreException { + String expected = workspace.getRevision(); + String bumped; + if (expected == null) { + bumped = "1"; + } else { + try { + bumped = String.valueOf(Long.parseLong(expected) + 1); + } catch (NumberFormatException e) { + // A corrupt revision must surface through the method's declared + // error model, not as an uncaught runtime exception the REST + // layer's generic handler turns into a bare 500 (CodeQL). + throw new IResourceStore.ResourceStoreException( + "Workspace revision for group " + workspace.getGroupId() + " is not numeric: '" + + expected + "'", + e); + } + } + workspace.setRevision(bumped); + workspace.setLastModified(Instant.now()); + if (expected == null) { + // Pre-revision document: no field to compare against. One plain write + // stamps the revision; every later write on this document is CAS'd. + update(workspace); + return true; + } + try { + IResourceStorage.IResource resource = storage.newResource(workspace.getId(), SINGLE_VERSION, workspace); + storage.storeIfFieldEquals(resource, "revision", expected); + return true; + } catch (IResourceStore.ResourceModifiedException e) { + workspace.setRevision(expected); + return false; + } catch (IResourceStore.ResourceNotFoundException e) { + LOGGER.warnf("Workspace %s disappeared during a revision-checked write", LogSanitizer.sanitize(workspace.getGroupId())); + workspace.setRevision(expected); + return false; + } catch (IOException e) { + throw new IResourceStore.ResourceStoreException("Failed revision-checked workspace update: " + e.getMessage(), e); + } + } + + @Override + public boolean casRunningDiscussion(GroupWorkspace workspace, String expectedRunning) + throws IResourceStore.ResourceStoreException { + try { + workspace.setLastModified(Instant.now()); + IResourceStorage.IResource resource = storage.newResource(workspace.getId(), SINGLE_VERSION, workspace); + // Conditional on the PERSISTED value — same cross-process atomicity as + // GroupConversationStore.updateIfState. An in-JVM check would only + // serialize one pod's cadence fires against itself. + storage.storeIfFieldEquals(resource, "runningDiscussionId", + expectedRunning != null ? expectedRunning : GroupWorkspace.NO_RUNNING_DISCUSSION); + return true; + } catch (IResourceStore.ResourceModifiedException e) { + return false; + } catch (IResourceStore.ResourceNotFoundException e) { + // Workspace deleted (group teardown) while a claim was in flight — + // treat as a lost claim rather than an error; the run must not start. + LOGGER.warnf("Workspace %s disappeared during a run claim", LogSanitizer.sanitize(workspace.getGroupId())); + return false; + } catch (IOException e) { + throw new IResourceStore.ResourceStoreException("Failed conditional workspace update: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/ai/labs/eddi/configs/groups/mongo/SharedArtifactStore.java b/src/main/java/ai/labs/eddi/configs/groups/mongo/SharedArtifactStore.java new file mode 100644 index 0000000000..eca09940e3 --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/groups/mongo/SharedArtifactStore.java @@ -0,0 +1,269 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.configs.groups.mongo; + +import ai.labs.eddi.configs.groups.ISharedArtifactStore; +import ai.labs.eddi.configs.groups.model.SharedArtifact; +import ai.labs.eddi.datastore.IResourceFilter; +import ai.labs.eddi.datastore.IResourceStorage; +import ai.labs.eddi.datastore.IResourceStorageFactory; +import ai.labs.eddi.datastore.IResourceStore; +import ai.labs.eddi.datastore.serialization.IDocumentBuilder; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.regex.Pattern; + +/** + * DB-agnostic store for {@link SharedArtifact}s (I17). Mirrors + * {@link GroupConversationStore}'s single-version runtime-document shape (the + * {@code mongo} package name is historic — this class is backend-neutral via + * {@link IResourceStorageFactory}). + *

+ * The version CAS goes through the numeric + * {@code storeIfFieldEquals(resource, "version", long)} overload: the + * artifact's {@code version} is a JSON number, and MongoDB's typed BSON + * equality would never match it against a string. + * + * @author ginccc + */ +@ApplicationScoped +public class SharedArtifactStore implements ISharedArtifactStore { + + private static final Logger LOGGER = Logger.getLogger(SharedArtifactStore.class); + + private static final int SINGLE_VERSION = 1; + + /** + * Both backends turn a String filter into an UNANCHORED regex — ids must be + * validated before they are interpolated into one. Group-conversation ids are + * Mongo ObjectIds or UUIDs on the two backends, both within this set. + */ + private static final Pattern SAFE_ID = Pattern.compile("[A-Za-z0-9-]+"); + + /** Same spin bound and rationale as {@code GroupConversationStore}. */ + private static final int MAX_ERASURE_PASSES = 1_000; + + private final IResourceStorage storage; + + @Inject + public SharedArtifactStore(IResourceStorageFactory storageFactory, IDocumentBuilder documentBuilder) { + this.storage = storageFactory.create("sharedartifacts", documentBuilder, SharedArtifact.class, + "groupConversationId", "ownerUserId"); + } + + @Override + public String create(SharedArtifact artifact) throws IResourceStore.ResourceStoreException { + try { + IResourceStorage.IResource resource = storage.newResource(artifact); + storage.store(resource); + String id = resource.getId(); + artifact.setId(id); + return id; + } catch (IOException e) { + throw new IResourceStore.ResourceStoreException("Failed to create shared artifact: " + e.getMessage(), e); + } + } + + @Override + public SharedArtifact read(String id) throws IResourceStore.ResourceNotFoundException, IResourceStore.ResourceStoreException { + try { + IResourceStorage.IResource resource = storage.read(id, SINGLE_VERSION); + if (resource == null) { + // Deliberately does not embed the caller-supplied id — reflected-value + // sink, same rule as GroupConversationStore.read. + throw new IResourceStore.ResourceNotFoundException("Shared artifact not found."); + } + SharedArtifact artifact = resource.getData(); + artifact.setId(id); + return artifact; + } catch (IOException e) { + throw new IResourceStore.ResourceStoreException("Failed to read shared artifact: " + e.getMessage(), e); + } + } + + @Override + public void updateIfVersion(SharedArtifact artifact, long expectedVersion) + throws IResourceStore.ResourceStoreException, IResourceStore.ResourceModifiedException { + try { + IResourceStorage.IResource resource = storage.newResource(artifact.getId(), SINGLE_VERSION, artifact); + storage.storeIfFieldEquals(resource, "version", expectedVersion); + } catch (IResourceStore.ResourceNotFoundException e) { + // Unchecked, so CAS call sites can tell "gone" (404) from a genuine + // version conflict (409) — same shape as GroupConversationGoneException. + throw new ArtifactGoneException("Shared artifact no longer exists.", e); + } catch (IOException e) { + throw new IResourceStore.ResourceStoreException("Failed to update shared artifact: " + e.getMessage(), e); + } + } + + @Override + public void delete(String id) throws IResourceStore.ResourceStoreException { + try { + storage.removeAllPermanently(id); + } catch (Exception e) { + throw new IResourceStore.ResourceStoreException("Failed to delete shared artifact: " + e.getMessage(), e); + } + } + + @Override + public List listByGroupConversationId(String groupConversationId) throws IResourceStore.ResourceStoreException { + if (groupConversationId == null || !SAFE_ID.matcher(groupConversationId).matches()) { + return List.of(); + } + try { + var filter = new IResourceFilter.QueryFilters( + List.of(new IResourceFilter.QueryFilter("groupConversationId", "^" + groupConversationId + "$"))); + var resourceIds = storage.findResources( + new IResourceFilter.QueryFilters[]{filter}, "createdAt", 0, IResourceStorage.MAX_RESULT_LIMIT); + List artifacts = new ArrayList<>(); + for (var resourceId : resourceIds) { + try { + var resource = storage.read(resourceId.getId(), SINGLE_VERSION); + if (resource == null) { + continue; + } + SharedArtifact artifact = resource.getData(); + // The anchored regex only narrows; equality decides — same + // narrow-then-recheck rule as every other cross-document filter. + if (!groupConversationId.equals(artifact.getGroupConversationId())) { + continue; + } + artifact.setId(resourceId.getId()); + artifacts.add(artifact); + } catch (IOException e) { + LOGGER.warnf("Skipping unreadable shared artifact %s: %s", resourceId.getId(), e.getMessage()); + } + } + // findResources sorts DESC on both backends; the contract is oldest first. + artifacts.sort(Comparator.comparing(SharedArtifact::getCreatedAt, + Comparator.nullsLast(Comparator.naturalOrder()))); + return artifacts; + } catch (Exception e) { + throw new IResourceStore.ResourceStoreException("Failed to list shared artifacts: " + e.getMessage(), e); + } + } + + @Override + public long deleteByGroupConversationId(String groupConversationId) throws IResourceStore.ResourceStoreException { + if (groupConversationId == null || !SAFE_ID.matcher(groupConversationId).matches()) { + return 0; + } + long deleted = 0; + var processed = new HashSet(); + // maxArtifactsPerDiscussion bounds a discussion's artifacts far below one + // page, but the loop stays honest anyway: delete until a pass finds + // nothing — with the same processed-set/no-progress guard as + // deleteAllForUser, so a row removeAllPermanently cannot dislodge is + // counted once and ends the loop instead of spinning it. + for (int pass = 0; pass < MAX_ERASURE_PASSES; pass++) { + List artifacts = listByGroupConversationId(groupConversationId); + if (artifacts.isEmpty()) { + return deleted; + } + long newThisPass = 0; + for (SharedArtifact artifact : artifacts) { + if (!processed.add(artifact.getId())) { + continue; + } + newThisPass++; + delete(artifact.getId()); + deleted++; + } + if (newThisPass == 0) { + LOGGER.warnf("Cascade delete made no progress; %d shared artifact(s) may remain", artifacts.size()); + return deleted; + } + } + return deleted; + } + + @Override + public long deleteAllForUser(String userId) throws IResourceStore.ResourceStoreException { + if (userId == null || userId.isBlank()) { + return 0; + } + long deleted = 0; + var processed = new HashSet(); + try { + var filter = new IResourceFilter.QueryFilters( + List.of(new IResourceFilter.QueryFilter("ownerUserId", "^" + escapeRegex(userId) + "$"))); + + // Page until an empty pass, always from offset 0 (rows are removed as we + // go); fail loudly on an owned row that will not delete. Contract and + // reasoning identical to GroupConversationStore.deleteAllForUser. + for (int pass = 0; pass < MAX_ERASURE_PASSES; pass++) { + var resourceIds = storage.findResources( + new IResourceFilter.QueryFilters[]{filter}, "createdAt", 0, IResourceStorage.MAX_RESULT_LIMIT); + if (resourceIds == null || resourceIds.isEmpty()) { + return deleted; + } + + long newThisPass = 0; + long failedToDelete = 0; + for (var resourceId : resourceIds) { + if (!processed.add(resourceId.getId())) { + continue; + } + newThisPass++; + try { + var resource = storage.read(resourceId.getId(), SINGLE_VERSION); + if (resource == null) { + continue; + } + if (!userId.equals(resource.getData().getOwnerUserId())) { + // regex matched more than it should have — never delete on it + LOGGER.warnf("Skipping shared artifact %s during erasure: ownerUserId is not an exact match", resourceId.getId()); + continue; + } + storage.removeAllPermanently(resourceId.getId()); + deleted++; + } catch (IOException e) { + failedToDelete++; + LOGGER.warnf("Failed to erase shared artifact %s: %s", resourceId.getId(), e.getMessage()); + } + } + + if (failedToDelete > 0) { + throw new IResourceStore.ResourceStoreException( + "Erasure incomplete: " + failedToDelete + " shared artifact(s) belonging to the user could not be deleted after " + + deleted + " successful deletion(s)"); + } + if (newThisPass == 0) { + return deleted; + } + } + LOGGER.warnf("Erasure stopped after %d passes; more shared artifacts may remain", MAX_ERASURE_PASSES); + } catch (IResourceStore.ResourceStoreException e) { + throw e; + } catch (Exception e) { + throw new IResourceStore.ResourceStoreException("Failed to delete shared artifacts for user: " + e.getMessage(), e); + } + return deleted; + } + + /** + * Backslash-escape the regex metacharacters shared by both backends' engines. + * Not {@link Pattern#quote}: its {@code \Q...\E} form is Java-specific and + * PostgreSQL rejects it. + */ + private static String escapeRegex(String value) { + StringBuilder sb = new StringBuilder(value.length() + 8); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if ("\\^$.|?*+()[]{}".indexOf(c) >= 0) { + sb.append('\\'); + } + sb.append(c); + } + return sb.toString(); + } +} diff --git a/src/main/java/ai/labs/eddi/configs/groups/rest/RestAgentGroupStore.java b/src/main/java/ai/labs/eddi/configs/groups/rest/RestAgentGroupStore.java index 0a18fcbccc..5dc93eceff 100644 --- a/src/main/java/ai/labs/eddi/configs/groups/rest/RestAgentGroupStore.java +++ b/src/main/java/ai/labs/eddi/configs/groups/rest/RestAgentGroupStore.java @@ -6,12 +6,15 @@ import ai.labs.eddi.configs.descriptors.IDocumentDescriptorStore; import ai.labs.eddi.configs.groups.IAgentGroupStore; +import ai.labs.eddi.configs.groups.IGroupWorkspaceStore; import ai.labs.eddi.configs.groups.IRestAgentGroupStore; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration; +import ai.labs.eddi.configs.groups.model.GroupWorkspace; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.DiscussionStyle; import ai.labs.eddi.configs.groups.model.DiscussionStylePresets; import ai.labs.eddi.configs.rest.RestVersionInfo; import ai.labs.eddi.configs.schema.IJsonSchemaCreator; +import ai.labs.eddi.engine.schedule.IScheduleStore; import ai.labs.eddi.datastore.IResourceStore; import ai.labs.eddi.datastore.IResourceStore.IResourceId; import ai.labs.eddi.configs.descriptors.model.DocumentDescriptor; @@ -43,14 +46,19 @@ public class RestAgentGroupStore implements IRestAgentGroupStore { private final IAgentGroupStore groupStore; private final IDocumentDescriptorStore documentDescriptorStore; private final IJsonSchemaCreator jsonSchemaCreator; + private final IGroupWorkspaceStore workspaceStore; + private final IScheduleStore scheduleStore; private final RestVersionInfo restVersionInfo; @Inject - public RestAgentGroupStore(IAgentGroupStore groupStore, IDocumentDescriptorStore documentDescriptorStore, IJsonSchemaCreator jsonSchemaCreator) { + public RestAgentGroupStore(IAgentGroupStore groupStore, IDocumentDescriptorStore documentDescriptorStore, IJsonSchemaCreator jsonSchemaCreator, + IGroupWorkspaceStore workspaceStore, IScheduleStore scheduleStore) { restVersionInfo = new RestVersionInfo<>(resourceURI, groupStore, documentDescriptorStore); this.groupStore = groupStore; this.documentDescriptorStore = documentDescriptorStore; this.jsonSchemaCreator = jsonSchemaCreator; + this.workspaceStore = workspaceStore; + this.scheduleStore = scheduleStore; } @Override @@ -80,6 +88,8 @@ private static String describeStyle(DiscussionStyle style) { case DELPHI -> "Anonymous opinion rounds to reduce groupthink and achieve convergence"; case DEBATE -> "Structured pro/con argumentation with rebuttal and judge"; case TASK_FORCE -> "Collaborative task accomplishment: plan, execute in parallel, verify, synthesize"; + case NEGOTIATION -> "Trade, not win/lose: positions, opening proposals, bargaining with a concession " + + "ledger, arbitration only if no agreement, synthesis"; case CUSTOM -> "User-defined phases for full control over the discussion flow"; }; } @@ -137,7 +147,36 @@ public Response duplicateGroup(String id, Integer version) { @Override public Response deleteGroup(String id, Integer version, Boolean permanent) { - return restVersionInfo.delete(id, version, permanent); + Response response = restVersionInfo.delete(id, version, permanent); + // I13: a permanently deleted group takes its standing workspace with it — + // backlog, cadences and metrics are meaningless without the config they + // belong to, and an orphaned cadence would keep firing into errors. A + // soft (versioned) delete keeps the workspace: the group can come back. + if (Boolean.TRUE.equals(permanent) && response.getStatus() < 300) { + try { + // Review finding: cadence ScheduleConfigurations outlive the + // workspace — enabled and due, the fire executor keeps selecting + // them and every fire errors with "No workspace exists". Retire + // the schedules BEFORE the workspace so a crash between the two + // leaves the recoverable order (schedules gone, workspace still + // deletable), never the orphaned one. + GroupWorkspace workspace = workspaceStore.find(id); + if (workspace != null) { + for (GroupWorkspace.Cadence cadence : workspace.getCadences()) { + try { + scheduleStore.deleteSchedule(cadence.scheduleRef()); + } catch (Exception e) { + LOG.warnf("Could not delete schedule %s of cadence %s while deleting group %s: %s", + cadence.scheduleRef(), cadence.cadenceId(), sanitize(id), e.getMessage()); + } + } + } + workspaceStore.deleteByGroupId(id); + } catch (Exception e) { + LOG.errorf(e, "Failed to cascade workspace deletion for group %s", sanitize(id)); + } + } + return response; } @Override diff --git a/src/main/java/ai/labs/eddi/configs/groups/rest/RestGroupTemplates.java b/src/main/java/ai/labs/eddi/configs/groups/rest/RestGroupTemplates.java new file mode 100644 index 0000000000..9d2f83732a --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/groups/rest/RestGroupTemplates.java @@ -0,0 +1,74 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.configs.groups.rest; + +import ai.labs.eddi.configs.groups.IRestAgentGroupStore; +import ai.labs.eddi.configs.groups.IRestGroupTemplates; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration; +import ai.labs.eddi.configs.groups.templates.GroupTemplateService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.Response; +import org.jboss.logging.Logger; + +import java.util.Map; + +import static ai.labs.eddi.utils.LogSanitizer.sanitize; + +/** + * REST implementation for {@link IRestGroupTemplates} (I10). Instantiation + * saves through {@link IRestAgentGroupStore#createGroup} — the SAME path a + * hand-written config takes, so versioning, descriptor sync and every save-time + * validator (vote phases, human members, facilitator, artifact specs, HITL + * config) apply identically. + * + * @author ginccc + */ +@ApplicationScoped +public class RestGroupTemplates implements IRestGroupTemplates { + + private static final Logger LOG = Logger.getLogger(RestGroupTemplates.class); + + private final GroupTemplateService templateService; + private final IRestAgentGroupStore groupStore; + + @Inject + public RestGroupTemplates(GroupTemplateService templateService, IRestAgentGroupStore groupStore) { + this.templateService = templateService; + this.groupStore = groupStore; + } + + @Override + public Response listTemplates() { + return Response.ok(templateService.list()).build(); + } + + @Override + public Response readTemplate(String templateId) { + var template = templateService.find(templateId); + if (template == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "No such template: " + templateId)).build(); + } + return Response.ok(Map.of("manifest", template.manifest(), "config", template.configNode())).build(); + } + + @Override + public Response instantiate(String templateId, InstantiateRequest request) { + try { + AgentGroupConfiguration config = templateService.instantiate(templateId, + request != null ? request.name() : null, + request != null ? request.roleAssignments() : null); + // The normal store path — a template earns no validation bypass. + return groupStore.createGroup(config); + } catch (IllegalArgumentException e) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", e.getMessage())).build(); + } catch (Exception e) { + LOG.errorf(e, "Failed to instantiate template %s", sanitize(templateId)); + return Response.serverError().build(); + } + } +} diff --git a/src/main/java/ai/labs/eddi/configs/groups/rest/RestGroupWorkspace.java b/src/main/java/ai/labs/eddi/configs/groups/rest/RestGroupWorkspace.java new file mode 100644 index 0000000000..4d0fdafbb7 --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/groups/rest/RestGroupWorkspace.java @@ -0,0 +1,286 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.configs.groups.rest; + +import ai.labs.eddi.configs.groups.IAgentGroupStore; +import ai.labs.eddi.configs.groups.IGroupWorkspaceStore; +import ai.labs.eddi.configs.groups.IRestGroupWorkspace; +import ai.labs.eddi.configs.groups.model.GroupWorkspace; +import ai.labs.eddi.configs.groups.model.GroupWorkspace.Cadence; +import ai.labs.eddi.configs.groups.model.SharedTaskList; +import ai.labs.eddi.configs.groups.model.SharedTaskList.TaskItem; +import ai.labs.eddi.datastore.IResourceStore; +import ai.labs.eddi.engine.runtime.internal.CronParser; +import ai.labs.eddi.engine.runtime.internal.TeamCadenceService; +import ai.labs.eddi.engine.schedule.IScheduleStore; +import ai.labs.eddi.engine.schedule.model.ScheduleConfiguration; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.Response; +import org.jboss.logging.Logger; + +import java.time.Instant; +import java.time.ZoneId; +import java.util.Map; +import java.util.UUID; + +import static ai.labs.eddi.utils.LogSanitizer.sanitize; + +/** + * REST implementation for {@link IRestGroupWorkspace} (I13). + *

+ * Reads run {@link TeamCadenceService#reconcile} first (read-repair): a cadence + * discussion that finished since the last fire is settled before the workspace + * is rendered, so a human never reads hour-old "running" state or stale + * metrics. + * + * @author ginccc + */ +@ApplicationScoped +public class RestGroupWorkspace implements IRestGroupWorkspace { + + private static final Logger LOG = Logger.getLogger(RestGroupWorkspace.class); + + /** A workspace is a team's schedule, not a cron farm. */ + static final int MAX_CADENCES_PER_WORKSPACE = 20; + /** The template renders into a discussion question — bound the prose. */ + static final int MAX_INPUT_TEMPLATE_LENGTH = 4000; + /** Lost-CAS retries before telling the caller to try again. */ + public static final int MAX_CAS_ATTEMPTS = 3; + + private final IGroupWorkspaceStore workspaceStore; + private final IAgentGroupStore groupStore; + private final IScheduleStore scheduleStore; + private final TeamCadenceService teamCadenceService; + private final SecurityIdentity identity; + + @Inject + public RestGroupWorkspace(IGroupWorkspaceStore workspaceStore, IAgentGroupStore groupStore, + IScheduleStore scheduleStore, TeamCadenceService teamCadenceService, SecurityIdentity identity) { + this.workspaceStore = workspaceStore; + this.groupStore = groupStore; + this.scheduleStore = scheduleStore; + this.teamCadenceService = teamCadenceService; + this.identity = identity; + } + + @Override + public Response readWorkspace(String groupId) { + try { + requireGroupExists(groupId); + GroupWorkspace workspace = workspaceStore.readOrCreate(groupId); + teamCadenceService.reconcile(workspace); + return Response.ok(workspace).build(); + } catch (IResourceStore.ResourceNotFoundException e) { + return Response.status(Response.Status.NOT_FOUND).entity(Map.of("error", e.getMessage())).build(); + } catch (Exception e) { + LOG.errorf(e, "Failed to read workspace for group %s", sanitize(groupId)); + return Response.serverError().build(); + } + } + + @Override + public Response readBacklog(String groupId) { + try { + requireGroupExists(groupId); + GroupWorkspace workspace = workspaceStore.readOrCreate(groupId); + teamCadenceService.reconcile(workspace); + return Response.ok(workspace.getBacklog().getTasks()).build(); + } catch (IResourceStore.ResourceNotFoundException e) { + return Response.status(Response.Status.NOT_FOUND).entity(Map.of("error", e.getMessage())).build(); + } catch (Exception e) { + LOG.errorf(e, "Failed to read backlog for group %s", sanitize(groupId)); + return Response.serverError().build(); + } + } + + @Override + public Response addBacklogTask(String groupId, BacklogTaskRequest request) { + if (request == null || request.subject() == null || request.subject().isBlank()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "subject is required")).build(); + } + // Final-review finding: the agent tool surface enforces these; a human + // surface bypassing them let one request grow the persisted document + // arbitrarily and made duplicate subjects ambiguous for the writeback's + // subject-based outcome matching. + if (request.subject().trim().length() > SharedTaskList.MAX_AGENT_TASK_SUBJECT_LENGTH) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "subject exceeds " + SharedTaskList.MAX_AGENT_TASK_SUBJECT_LENGTH + + " characters")) + .build(); + } + if (request.description() != null + && request.description().length() > SharedTaskList.MAX_AGENT_TASK_DESCRIPTION_LENGTH) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "description exceeds " + SharedTaskList.MAX_AGENT_TASK_DESCRIPTION_LENGTH + + " characters")) + .build(); + } + try { + requireGroupExists(groupId); + String subject = request.subject().trim(); + // Optimistic-concurrency retry (review finding): the cap and duplicate + // checks run against THIS caller's snapshot; a plain whole-document + // update let two concurrent adds both pass under the cap and the later + // write drop the earlier task. A lost CAS re-reads and re-validates. + for (int attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) { + GroupWorkspace workspace = workspaceStore.readOrCreate(groupId); + if (workspace.getBacklog().size() >= GroupWorkspace.MAX_BACKLOG_SIZE) { + // Actionable, not silent: say WHAT is full and what to do about it. + return Response.status(Response.Status.CONFLICT) + .entity(Map.of("error", "The backlog already holds " + GroupWorkspace.MAX_BACKLOG_SIZE + + " tasks — complete or delete existing tasks before adding more")) + .build(); + } + if (workspace.getBacklog().getTasks().stream().anyMatch(t -> subject.equalsIgnoreCase(t.subject()))) { + return Response.status(Response.Status.CONFLICT) + .entity(Map.of("error", "A backlog task with that subject already exists — " + + "writeback matches outcomes by subject, so subjects must be unique")) + .build(); + } + TaskItem task = workspace.getBacklog().addTask(new TaskItem( + subject, + request.description() != null ? request.description() : "", + request.priority())); + if (workspaceStore.casRevision(workspace)) { + return Response.status(Response.Status.CREATED).entity(task).build(); + } + } + return Response.status(Response.Status.CONFLICT) + .entity(Map.of("error", "The workspace is being modified concurrently — retry the request")) + .build(); + } catch (IResourceStore.ResourceNotFoundException e) { + return Response.status(Response.Status.NOT_FOUND).entity(Map.of("error", e.getMessage())).build(); + } catch (Exception e) { + LOG.errorf(e, "Failed to add backlog task for group %s", sanitize(groupId)); + return Response.serverError().build(); + } + } + + @Override + public Response addCadence(String groupId, CadenceRequest request) { + if (request == null || request.cronExpression() == null || request.cronExpression().isBlank()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "cronExpression is required")).build(); + } + try { + requireGroupExists(groupId); + ZoneId zone = request.timeZone() != null && !request.timeZone().isBlank() + ? ZoneId.of(request.timeZone()) + : ZoneId.of("UTC"); + // Validate the cron by computing the first fire — an unparseable + // expression must fail HERE, not silently never fire. + Instant firstFire = CronParser.computeNextFire(request.cronExpression().trim(), Instant.now(), zone); + + GroupWorkspace workspace = workspaceStore.readOrCreate(groupId); + if (workspace.getCadences().size() >= MAX_CADENCES_PER_WORKSPACE) { + return Response.status(Response.Status.CONFLICT) + .entity(Map.of("error", "This workspace already has " + MAX_CADENCES_PER_WORKSPACE + + " cadences — delete one before adding more")) + .build(); + } + if (request.inputTemplate() != null && request.inputTemplate().length() > MAX_INPUT_TEMPLATE_LENGTH) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "inputTemplate exceeds " + MAX_INPUT_TEMPLATE_LENGTH + " characters")) + .build(); + } + String cadenceId = UUID.randomUUID().toString(); + String principal = identity != null && identity.getPrincipal() != null + && identity.getPrincipal().getName() != null && !identity.getPrincipal().getName().isBlank() + ? identity.getPrincipal().getName() + : TeamCadenceService.FALLBACK_USER_ID; + + var schedule = new ScheduleConfiguration(); + schedule.setName("team-cadence-" + groupId + "-" + cadenceId); + schedule.setTriggerType(ScheduleConfiguration.TriggerType.CRON); + schedule.setCronExpression(request.cronExpression().trim()); + schedule.setTimeZone(zone.getId()); + schedule.setEnabled(true); + schedule.setUserId(principal); + schedule.setCreatedBy(principal); + schedule.setNextFire(firstFire); + schedule.setCreatedAt(Instant.now()); + schedule.setMetadata(Map.of( + TeamCadenceService.METADATA_TYPE_KEY, TeamCadenceService.METADATA_TYPE_CADENCE, + TeamCadenceService.METADATA_GROUP_ID_KEY, groupId, + TeamCadenceService.METADATA_CADENCE_ID_KEY, cadenceId)); + String scheduleId = scheduleStore.createSchedule(schedule); + + var cadence = new Cadence(cadenceId, scheduleId, request.inputTemplate(), + request.maxBacklogTasksPerRun(), request.maxCostPerRun(), principal); + workspace.addCadence(cadence); + try { + workspaceStore.update(workspace); + } catch (Exception e) { + // Compensate (review finding): the schedule exists but no cadence + // names its scheduleRef — it would fire into "cadence no longer + // exists" forever and deleteCadence could never reach it. + try { + scheduleStore.deleteSchedule(scheduleId); + } catch (Exception cleanupFailure) { + LOG.errorf(cleanupFailure, "Orphaned schedule %s for group %s after a failed workspace write", + scheduleId, sanitize(groupId)); + } + throw e; + } + LOG.infof("Cadence %s created for group %s (schedule %s, cron '%s')", cadenceId, sanitize(groupId), + scheduleId, sanitize(request.cronExpression())); + return Response.status(Response.Status.CREATED).entity(cadence).build(); + } catch (IResourceStore.ResourceNotFoundException e) { + return Response.status(Response.Status.NOT_FOUND).entity(Map.of("error", e.getMessage())).build(); + } catch (IllegalArgumentException | java.time.DateTimeException e) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Invalid cadence definition: " + e.getMessage())).build(); + } catch (Exception e) { + LOG.errorf(e, "Failed to add cadence for group %s", sanitize(groupId)); + return Response.serverError().build(); + } + } + + @Override + public Response deleteCadence(String groupId, String cadenceId) { + try { + requireGroupExists(groupId); + GroupWorkspace workspace = workspaceStore.find(groupId); + if (workspace == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "No workspace exists for this group")).build(); + } + Cadence cadence = workspace.getCadences().stream() + .filter(c -> cadenceId.equals(c.cadenceId())) + .findFirst().orElse(null); + if (cadence == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "No such cadence")).build(); + } + // Schedule first: a cadence whose schedule outlives it would keep + // firing into "cadence no longer exists" errors forever. + try { + scheduleStore.deleteSchedule(cadence.scheduleRef()); + } catch (Exception e) { + LOG.warnf("Could not delete schedule %s for cadence %s: %s", cadence.scheduleRef(), + sanitize(cadenceId), sanitize(e.getMessage())); + } + workspace.removeCadence(cadenceId); + workspaceStore.update(workspace); + return Response.noContent().build(); + } catch (IResourceStore.ResourceNotFoundException e) { + return Response.status(Response.Status.NOT_FOUND).entity(Map.of("error", e.getMessage())).build(); + } catch (Exception e) { + LOG.errorf(e, "Failed to delete cadence %s for group %s", sanitize(cadenceId), sanitize(groupId)); + return Response.serverError().build(); + } + } + + /** 404 for a workspace under a group id that names no stored group config. */ + private void requireGroupExists(String groupId) throws IResourceStore.ResourceNotFoundException, + IResourceStore.ResourceStoreException { + if (groupStore.getCurrentResourceId(groupId) == null) { + throw new IResourceStore.ResourceNotFoundException("Group not found."); + } + } +} diff --git a/src/main/java/ai/labs/eddi/configs/groups/templates/GroupTemplateService.java b/src/main/java/ai/labs/eddi/configs/groups/templates/GroupTemplateService.java new file mode 100644 index 0000000000..83da5d4ec5 --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/groups/templates/GroupTemplateService.java @@ -0,0 +1,217 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.configs.groups.templates; + +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.GroupMember; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Org/team preset templates (I10): packaged group configurations an enterprise + * can instantiate by assigning agents to named roles — "research pod" and + * "decision board" instead of {@code contextScope: OWN_FEEDBACK}. Ships last by + * design, so every template references only features that exist; instantiating + * each template against the save-time validators doubles as an integration test + * of the whole Wave 1–3 config surface. + *

+ * Templates live on the classpath under {@code group-templates/} (the + * {@code initial-agents/} pattern: an index file naming each resource). A + * template file carries a {@code manifest} (id, title, description, + * requiredRoles) and a {@code config} — a complete, valid + * {@link AgentGroupConfiguration} whose member {@code agentId}s (and + * {@code moderatorAgentId}) are {@code $role} placeholders that + * {@link #instantiate} substitutes from the caller's role assignments. + * Placeholders are the ONLY templating: everything else is literal config, so + * what you read in the JSON is what the store validates and saves. + * + * @author ginccc + */ +@ApplicationScoped +public class GroupTemplateService { + + private static final Logger LOGGER = Logger.getLogger(GroupTemplateService.class); + + private static final String TEMPLATE_ROOT = "/group-templates/"; + private static final String INDEX_RESOURCE = TEMPLATE_ROOT + "index.txt"; + private static final String PLACEHOLDER_PREFIX = "$"; + + private final ObjectMapper objectMapper; + + /** Insertion-ordered: the index file's order is the display order. */ + private final Map templates = new LinkedHashMap<>(); + + @Inject + public GroupTemplateService(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** One required role: the key a caller's roleAssignments must provide. */ + public record RequiredRole(String role, String description) { + } + + public record TemplateManifest(String templateId, String title, String description, + List requiredRoles) { + } + + /** + * A loaded template. {@code configNode} stays JSON until instantiation so a + * listing never pays for (or fails on) config materialization. + */ + public record GroupTemplate(TemplateManifest manifest, JsonNode configNode) { + } + + @PostConstruct + public void loadTemplates() { + List fileNames = readIndex(); + for (String fileName : fileNames) { + try (InputStream in = getClass().getResourceAsStream(TEMPLATE_ROOT + fileName)) { + if (in == null) { + LOGGER.errorf("Group template '%s' is named in the index but missing from the classpath", fileName); + continue; + } + JsonNode root = objectMapper.readTree(in); + TemplateManifest manifest = objectMapper.treeToValue(root.path("manifest"), TemplateManifest.class); + JsonNode configNode = root.path("config"); + if (manifest == null || manifest.templateId() == null || manifest.templateId().isBlank() + || configNode.isMissingNode() || !configNode.isObject()) { + LOGGER.errorf("Group template '%s' lacks a manifest.templateId or a config object — skipped", fileName); + continue; + } + templates.put(manifest.templateId(), new GroupTemplate(manifest, configNode)); + } catch (Exception e) { + // One malformed template must not take down startup or hide the + // others — but it must be LOUD in the log. + LOGGER.errorf(e, "Failed to load group template '%s'", fileName); + } + } + LOGGER.infof("Loaded %d group template(s): %s", templates.size(), templates.keySet()); + } + + private List readIndex() { + List names = new ArrayList<>(); + try (InputStream in = getClass().getResourceAsStream(INDEX_RESOURCE)) { + if (in == null) { + LOGGER.warnf("No group-template index at %s — no templates available", INDEX_RESOURCE); + return names; + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (!line.isEmpty() && !line.startsWith("#")) { + names.add(line); + } + } + } + } catch (Exception e) { + LOGGER.errorf(e, "Failed to read the group-template index"); + } + return names; + } + + /** All template manifests, in index order. */ + public List list() { + return templates.values().stream().map(GroupTemplate::manifest).toList(); + } + + /** The template with this id, or {@code null}. */ + public GroupTemplate find(String templateId) { + return templateId != null ? templates.get(templateId) : null; + } + + /** + * Materializes a template into a savable {@link AgentGroupConfiguration}. + *

+ * Fails loudly and completely BEFORE building anything: every required role + * must be assigned a non-blank agent id, and no unknown assignment keys are + * accepted (a typo'd role name must not silently leave a {@code $placeholder} + * in the stored roster). The returned config has not been validated by the + * store yet — the caller saves it through the normal store path, where every + * save-time validator applies exactly as it would to a hand-written config. + * + * @param name + * the new group's name; {@code null}/blank keeps the template's + * @throws IllegalArgumentException + * naming the missing/unknown roles, or an unreadable config + */ + public AgentGroupConfiguration instantiate(String templateId, String name, Map roleAssignments) { + GroupTemplate template = find(templateId); + if (template == null) { + throw new IllegalArgumentException("No such template: " + templateId); + } + Map assignments = roleAssignments != null ? roleAssignments : Map.of(); + + List missing = new ArrayList<>(); + for (RequiredRole required : template.manifest().requiredRoles()) { + String assigned = assignments.get(required.role()); + if (assigned == null || assigned.isBlank()) { + missing.add(required.role()); + } + } + if (!missing.isEmpty()) { + throw new IllegalArgumentException("Missing role assignment(s): " + String.join(", ", missing)); + } + List known = template.manifest().requiredRoles().stream().map(RequiredRole::role).toList(); + List unknown = assignments.keySet().stream().filter(k -> !known.contains(k)).sorted().toList(); + if (!unknown.isEmpty()) { + throw new IllegalArgumentException("Unknown role(s): " + String.join(", ", unknown) + + " — this template's roles are: " + String.join(", ", known)); + } + + AgentGroupConfiguration config; + try { + config = objectMapper.treeToValue(template.configNode(), AgentGroupConfiguration.class); + } catch (Exception e) { + throw new IllegalArgumentException("Template '" + templateId + "' carries an unreadable config: " + + e.getMessage(), e); + } + + if (config.getMembers() != null) { + List resolved = new ArrayList<>(config.getMembers().size()); + for (GroupMember member : config.getMembers()) { + resolved.add(new GroupMember(resolvePlaceholder(member.agentId(), assignments, templateId), + member.displayName(), member.speakingOrder(), member.role(), member.memberType())); + } + config.setMembers(resolved); + } + config.setModeratorAgentId(resolvePlaceholder(config.getModeratorAgentId(), assignments, templateId)); + if (name != null && !name.isBlank()) { + config.setName(name.trim()); + } + return config; + } + + /** + * {@code $role} → the assigned agent id. A placeholder with no assignment is a + * template/manifest mismatch — an authoring bug, thrown as such rather than + * stored as a literal {@code $role} agent id. + */ + private static String resolvePlaceholder(String value, Map assignments, String templateId) { + if (value == null || !value.startsWith(PLACEHOLDER_PREFIX)) { + return value; + } + String role = value.substring(PLACEHOLDER_PREFIX.length()); + String assigned = assignments.get(role); + if (assigned == null || assigned.isBlank()) { + throw new IllegalArgumentException("Template '" + templateId + "' uses placeholder '" + value + + "' but its manifest declares no such required role — fix the template"); + } + return assigned.trim(); + } +} diff --git a/src/main/java/ai/labs/eddi/configs/properties/IUserMemoryStore.java b/src/main/java/ai/labs/eddi/configs/properties/IUserMemoryStore.java index 1d6bdad926..15706dc946 100644 --- a/src/main/java/ai/labs/eddi/configs/properties/IUserMemoryStore.java +++ b/src/main/java/ai/labs/eddi/configs/properties/IUserMemoryStore.java @@ -116,8 +116,13 @@ public static RecallWindow forMaxEntries(int maxEntries) { // === Queries === /** - * Returns entries visible to the given agent in the given groups. Combines: - * self(agentId) + group(groupIds) + global. + * Returns entries visible to the given agent in the given groups. Combines the + * user's own scope — self(agentId) + group(groupIds) + global — with, + * additively, TEAM-OWNED entries (I8): lessons stored under the synthetic owner + * {@link #TEAM_OWNER_PREFIX}{@code +groupId} with {@code group} visibility, for + * each supplied group. The team branch never widens the user's own scope — a + * personal entry of another human user is unreachable through it, because team + * owner ids are derived from the supplied group ids, not caller-supplied. * * @param recallOrder * "most_recent" (updatedAt DESC) or "most_accessed" (accessCount @@ -128,6 +133,14 @@ public static RecallWindow forMaxEntries(int maxEntries) { List getVisibleEntries(String userId, String agentId, List groupIds, String recallOrder, int maxEntries) throws IResourceStore.ResourceStoreException; + /** + * Owner prefix for TEAM-OWNED memory (I8): a group's retro lessons are stored + * under the synthetic user {@code "group:"+groupId} so they belong to the team, + * not to whichever human happened to run the discussion — and survive that + * human's GDPR erasure without carrying their identity. + */ + String TEAM_OWNER_PREFIX = "group:"; + /** * Text filter across keys and values (v1: regex, v2: semantic search). */ diff --git a/src/main/java/ai/labs/eddi/configs/properties/mongo/MongoUserMemoryStore.java b/src/main/java/ai/labs/eddi/configs/properties/mongo/MongoUserMemoryStore.java index 68c74b741e..562361ff0c 100644 --- a/src/main/java/ai/labs/eddi/configs/properties/mongo/MongoUserMemoryStore.java +++ b/src/main/java/ai/labs/eddi/configs/properties/mongo/MongoUserMemoryStore.java @@ -230,9 +230,18 @@ public List getVisibleEntries(String userId, String agentId, Li } /** - * Visibility filter for a recall: self(agentId) OR group(groupIds) OR global. + * Visibility filter for a recall: the user's own scope — self(agentId) OR + * group(groupIds) OR global, always constrained to the user's own entries — + * plus, additively, the TEAM-OWNED scope (I8): entries whose owner is the + * synthetic {@code "group:"+groupId} user with {@code group} visibility. + * Package-private static so the filter's shape is directly assertable. + *

+ * The team branch is deliberately narrow: it matches ONLY synthetic team owners + * derived from the supplied group ids, with group visibility, with a group-id + * overlap — never another human user's entries. The user's own branch is + * untouched, so personal recall behaves exactly as before. */ - private Bson buildVisibilityFilter(String userId, String agentId, List groupIds) { + static Bson buildVisibilityFilter(String userId, String agentId, List groupIds) { List visibilityFilters = new ArrayList<>(); // Self: entries created by this agent for this user @@ -246,7 +255,18 @@ private Bson buildVisibilityFilter(String userId, String agentId, List g // Global: all global entries for this user visibilityFilters.add(eq(FIELD_VISIBILITY, Visibility.global.name())); - return and(eq(FIELD_USER_ID, userId), or(visibilityFilters)); + Bson userScope = and(eq(FIELD_USER_ID, userId), or(visibilityFilters)); + if (groupIds == null || groupIds.isEmpty()) { + return userScope; + } + + // I8: team-owned lessons. Owner ids are DERIVED from the supplied group + // ids, never caller-supplied strings — a caller cannot name an arbitrary + // owner this way. + List teamOwners = groupIds.stream().map(gid -> IUserMemoryStore.TEAM_OWNER_PREFIX + gid).toList(); + Bson teamScope = and(in(FIELD_USER_ID, teamOwners), eq(FIELD_VISIBILITY, Visibility.group.name()), + in(FIELD_GROUP_IDS, groupIds)); + return or(userScope, teamScope); } /** diff --git a/src/main/java/ai/labs/eddi/datastore/IResourceStorage.java b/src/main/java/ai/labs/eddi/datastore/IResourceStorage.java index 000d933dcc..148d82c032 100644 --- a/src/main/java/ai/labs/eddi/datastore/IResourceStorage.java +++ b/src/main/java/ai/labs/eddi/datastore/IResourceStorage.java @@ -153,6 +153,24 @@ default void storeIfFieldEquals(IResource newResource, String fieldName, Stri + " — a compare-and-swap must never silently degrade to an unconditional store"); } + /** + * As {@link #storeIfFieldEquals(IResource, String, String)}, but comparing a + * JSON number field. A separate overload because the two backends + * disagree about text-comparing numbers: PostgreSQL's {@code data ->> field} + * renders a JSON number as text so {@code "3"} matches, but MongoDB's typed + * BSON equality never matches an int64 against a string — a string-typed CAS on + * a numeric field would "work" on one backend and silently never match on the + * other. Used by the shared-artifact store's version CAS (I17). + *

+ * Same no-fallback contract as the String overload. + */ + default void storeIfFieldEquals(IResource newResource, String fieldName, long expectedValue) + throws IResourceStore.ResourceModifiedException, IResourceStore.ResourceNotFoundException { + throw new UnsupportedOperationException( + "storeIfFieldEquals(long) is not implemented by " + getClass().getName() + + " — a compare-and-swap must never silently degrade to an unconditional store"); + } + /** * Archive {@code history} and apply the version-checked update as ONE unit of * work. diff --git a/src/main/java/ai/labs/eddi/datastore/mongo/MongoResourceStorage.java b/src/main/java/ai/labs/eddi/datastore/mongo/MongoResourceStorage.java index b24fbbe88a..240997ead6 100644 --- a/src/main/java/ai/labs/eddi/datastore/mongo/MongoResourceStorage.java +++ b/src/main/java/ai/labs/eddi/datastore/mongo/MongoResourceStorage.java @@ -147,6 +147,28 @@ public void storeIfFieldEquals(IResource newResource, String fieldName, Strin } } + @Override + public void storeIfFieldEquals(IResource newResource, String fieldName, long expectedValue) + throws IResourceStore.ResourceModifiedException, IResourceStore.ResourceNotFoundException { + Resource resource = checkInternalResource(newResource); + // Typed BSON equality — the String overload's Filters.eq(field, "3") never + // matches an int64 3, which is exactly why this overload exists. + var result = currentCollection.replaceOne( + Filters.and( + Filters.eq(ID_FIELD, new ObjectId(resource.getId())), + Filters.eq(fieldName, expectedValue)), + resource.getMongoDocument()); + if (result.getMatchedCount() == 0) { + long exists = currentCollection.countDocuments(Filters.eq(ID_FIELD, new ObjectId(resource.getId()))); + if (exists == 0) { + throw new IResourceStore.ResourceNotFoundException( + String.format("Resource no longer exists (id=%s)", resource.getId())); + } + throw new IResourceStore.ResourceModifiedException( + String.format("Resource field '%s' was not %d (id=%s)", fieldName, expectedValue, resource.getId())); + } + } + @Override public void createNew(IResource currentResource) { Resource resource = checkInternalResource(currentResource); diff --git a/src/main/java/ai/labs/eddi/datastore/postgres/PostgresResourceStorage.java b/src/main/java/ai/labs/eddi/datastore/postgres/PostgresResourceStorage.java index 5b65615891..69d58e1ba3 100644 --- a/src/main/java/ai/labs/eddi/datastore/postgres/PostgresResourceStorage.java +++ b/src/main/java/ai/labs/eddi/datastore/postgres/PostgresResourceStorage.java @@ -304,6 +304,16 @@ public void storeIfFieldEquals(IResource newResource, String fieldName, Strin } } + @Override + public void storeIfFieldEquals(IResource newResource, String fieldName, long expectedValue) + throws IResourceStore.ResourceModifiedException, IResourceStore.ResourceNotFoundException { + // data ->> field renders a JSON number as its canonical text ("3"), so on + // this backend the numeric CAS is a text comparison against the rendered + // number. The overload exists for MongoDB, whose typed BSON equality has + // no such coincidence — see IResourceStorage's Javadoc. + storeIfFieldEquals(newResource, fieldName, String.valueOf(expectedValue)); + } + @Override public void createNew(IResource resource) { Resource pgResource = checkInternalResource(resource); diff --git a/src/main/java/ai/labs/eddi/datastore/postgres/PostgresUserMemoryStore.java b/src/main/java/ai/labs/eddi/datastore/postgres/PostgresUserMemoryStore.java index e43c8a1b15..58ce06ac74 100644 --- a/src/main/java/ai/labs/eddi/datastore/postgres/PostgresUserMemoryStore.java +++ b/src/main/java/ai/labs/eddi/datastore/postgres/PostgresUserMemoryStore.java @@ -74,7 +74,7 @@ source_conversation_id VARCHAR(255), * optional group clause, the closing parenthesis, an ORDER BY and a LIMIT. */ private static final String VISIBILITY_SELECT = """ - SELECT * FROM usermemories WHERE user_id = ? AND ( + SELECT * FROM usermemories WHERE ((user_id = ? AND ( (visibility = 'self' AND source_agent_id = ?) OR (visibility = 'global') """; @@ -314,24 +314,48 @@ public List getVisibleEntries(String userId, String agentId, Li } /** - * Visibility filter for a recall: self(agentId) OR group(groupIds) OR global. + * Visibility filter for a recall: the user's own scope — self(agentId) OR + * group(groupIds) OR global — plus, additively, the TEAM-OWNED scope (I8): + * entries whose owner is the synthetic {@code "group:"+groupId} user with + * {@code group} visibility. Mirrors the Mongo store's + * {@code buildVisibilityFilter} exactly; the two must not drift. + * Package-private static so the SQL shape is directly assertable. + *

* The {@code ??|} is the JDBC escape for PostgreSQL's {@code ?|} JSONB overlap - * operator — a single {@code ?} would be parsed as a bind parameter. + * operator — a single {@code ?} would be parsed as a bind parameter. Bind order + * (see {@code collectInto}): userId, agentId, then — only when groups are + * supplied — the group ids (user scope), the derived team owner ids, and the + * group ids again (team overlap). */ - private String buildVisibilityQuery(List groupIds) { + static String buildVisibilityQuery(List groupIds) { StringBuilder sql = new StringBuilder(VISIBILITY_SELECT); - if (groupIds != null && !groupIds.isEmpty()) { + boolean hasGroups = groupIds != null && !groupIds.isEmpty(); + if (hasGroups) { // `??|` is pgjdbc's escape for the jsonb `?|` overlap operator — a bare `?` // would be parsed by the driver as a bind placeholder. sql.append(" OR (visibility = 'group' AND group_ids ??| ARRAY["); - for (int i = 0; i < groupIds.size(); i++) { - sql.append(i > 0 ? ",?" : "?"); - } + appendPlaceholders(sql, groupIds.size()); + sql.append("])"); + } + sql.append("))"); + if (hasGroups) { + // I8: team-owned lessons. Owner ids are DERIVED from the supplied + // group ids at bind time, never caller-supplied strings. + sql.append(" OR (visibility = 'group' AND user_id IN ("); + appendPlaceholders(sql, groupIds.size()); + sql.append(") AND group_ids ??| ARRAY["); + appendPlaceholders(sql, groupIds.size()); sql.append("])"); } return sql.append(")").toString(); } + private static void appendPlaceholders(StringBuilder sql, int count) { + for (int i = 0; i < count; i++) { + sql.append(i > 0 ? ",?" : "?"); + } + } + /** * {@code most_accessed} recall: the bulk of the window is filled by access * count, a reserved slice by recency (the split is computed by the shared @@ -397,7 +421,16 @@ private void collectInto(Connection conn, Set seen, List groupConversationStoreInstance; + private final Instance sharedArtifactStoreInstance; private final IScheduleStore scheduleStore; private final ICache userConversationCache; @@ -77,6 +79,7 @@ public GdprComplianceService(IUserMemoryStore userMemoryStore, IConversationDescriptorStore conversationDescriptorStore, IConversationCheckpointStore checkpointStore, Instance groupConversationStoreInstance, + Instance sharedArtifactStoreInstance, IScheduleStore scheduleStore, ICacheFactory cacheFactory) { this.userMemoryStore = userMemoryStore; @@ -90,6 +93,7 @@ public GdprComplianceService(IUserMemoryStore userMemoryStore, this.conversationDescriptorStore = conversationDescriptorStore; this.checkpointStore = checkpointStore; this.groupConversationStoreInstance = groupConversationStoreInstance; + this.sharedArtifactStoreInstance = sharedArtifactStoreInstance; this.scheduleStore = scheduleStore; this.userConversationCache = cacheFactory.getCache(USER_CONVERSATION_CACHE_NAME); } @@ -130,6 +134,7 @@ private static String userConversationCacheKey(String intent, String userId) { *

  • Delete all managed conversation mappings (and invalidate their * cache)
  • *
  • Delete all group conversation transcripts
  • + *
  • Delete all shared artifacts owned by the user
  • *
  • Delete all schedules owned by the user
  • *
  • Pseudonymize database log entries
  • *
  • Pseudonymize audit ledger entries
  • @@ -306,6 +311,23 @@ public GdprDeletionResult deleteUserData(String userId) { pseudonym); } + // 5c2. Delete shared artifacts (I17). Artifacts carry ownerUserId (the + // owning discussion's user) precisely so this sweep works even when the + // parent discussion document is already gone. + long sharedArtifactsDeleted = 0; + try { + if (sharedArtifactStoreInstance.isResolvable()) { + sharedArtifactsDeleted = sharedArtifactStoreInstance.get().deleteAllForUser(userId); + if (sharedArtifactsDeleted > 0) { + LOGGER.infof("[GDPR] Deleted %d shared artifacts [%s]", + sharedArtifactsDeleted, pseudonym); + } + } + } catch (Exception e) { + LOGGER.errorf(e, "[GDPR] Failed to delete shared artifacts [%s]", + pseudonym); + } + // 5d. Delete schedules owned by the user. Left behind, they keep firing new // conversations under the erased identity — recreating the data forever. long schedulesDeleted = 0; @@ -346,9 +368,9 @@ public GdprDeletionResult deleteUserData(String userId) { LOGGER.infof("[GDPR] Erasure cascade complete [%s]: " + "memories=%d, conversations=%d, checkpoints=%d, mappings=%d, " - + "groupConversations=%d, schedules=%d, logs=%d, audit=%d", + + "groupConversations=%d, sharedArtifacts=%d, schedules=%d, logs=%d, audit=%d", pseudonym, memoriesDeleted, conversationsDeleted, checkpointsDeleted, - mappingsDeleted, groupConversationsDeleted, schedulesDeleted, + mappingsDeleted, groupConversationsDeleted, sharedArtifactsDeleted, schedulesDeleted, logsPseudonymized, auditPseudonymized); // Write compliance event to immutable audit ledger @@ -360,6 +382,7 @@ public GdprDeletionResult deleteUserData(String userId) { auditDetails.put("conversationsDeleted", conversationsDeleted); auditDetails.put("mappingsDeleted", mappingsDeleted); auditDetails.put("groupConversationsDeleted", groupConversationsDeleted); + auditDetails.put("sharedArtifactsDeleted", sharedArtifactsDeleted); auditDetails.put("schedulesDeleted", schedulesDeleted); auditDetails.put("logsPseudonymized", logsPseudonymized); auditDetails.put("auditPseudonymized", auditPseudonymized); diff --git a/src/main/java/ai/labs/eddi/engine/hitl/HitlAccessGuard.java b/src/main/java/ai/labs/eddi/engine/hitl/HitlAccessGuard.java index 0ed36d05b1..6bceaf463f 100644 --- a/src/main/java/ai/labs/eddi/engine/hitl/HitlAccessGuard.java +++ b/src/main/java/ai/labs/eddi/engine/hitl/HitlAccessGuard.java @@ -139,8 +139,10 @@ public void requireGroupConversationHitlAccess(String groupId, String groupConve /** * Owner-scoped pending-approval inbox for group conversations: admins and - * approvers see all; other callers see only their own (post-filtered by owner, - * since the group store has no owner-scoped query); anonymous callers see + * approvers see all; other callers see their own — or, for I6 human-turn + * pauses, entries waiting on THEM as the pending member, since a human member + * usually does not own the conversation whose turn they owe. Post-filtered, + * since the group store has no owner-scoped query; anonymous callers see * nothing (fail-closed). {@code groupId == null} lists across all groups * (cross-group inbox). */ @@ -154,6 +156,83 @@ public List listScopedGroupPendingApprovals(String group if (callerId == null || callerId.isBlank()) { return List.of(); } - return scoped.filter(summary -> callerId.equals(summary.getUserId())).toList(); + return scoped.filter(summary -> callerId.equals(summary.getUserId()) + || callerId.equals(summary.getPendingMemberId())).toList(); + } + + /** + * Read access to a group conversation's approval/pause STATUS (I6): the + * owner/admin/approver rule, plus the one extra reader the strict guard cannot + * admit — the HUMAN member a pending turn is waiting on. The inbox tells that + * member they are up; without this, the status API (where their rendered prompt + * lives) would refuse to show them what they were asked. Read-only surfaces + * only — approve/cancel/submit keep their own guards. + * + * @throws ForbiddenException + * if the caller may not read this group conversation's status. + */ + public void requireGroupConversationReadAccess(String groupId, String groupConversationId) { + GroupConversation gc; + try { + gc = groupConversationService.readGroupConversation(groupConversationId); + } catch (ResourceNotFoundException e) { + LOGGER.debugf("Group conversation not found for read-access check: %s", sanitize(groupConversationId)); + return; // the actual operation 404s + } catch (Exception e) { + throw new ForbiddenException("Access denied: unable to verify group conversation"); + } + if (groupId != null && !groupId.equals(gc.getGroupId())) { + LOGGER.infof("Group conversation %s does not belong to group %s", + sanitize(groupConversationId), sanitize(groupId)); + throw new jakarta.ws.rs.NotFoundException("Group conversation not found."); + } + String callerId = identity != null && identity.getPrincipal() != null ? identity.getPrincipal().getName() : null; + if (gc.getPendingHumanInput() != null && callerId != null + && callerId.equals(gc.getPendingHumanInput().memberId())) { + return; + } + ownershipValidator.requireOwnerAdminOrApprover(identity, gc.getUserId(), "group conversation"); + } + + /** + * Authorization for submitting a HUMAN member's turn (I6): the caller must BE + * that member (their principal equals the pending turn's member id) or an + * admin. Deliberately narrower than the approve surface — an + * {@code eddi-approver} may decide approvals they do not own, but SPEAKING as + * another human is impersonation, not review; only the admin break-glass + * crosses that line. A wrong-group path 404s without leaking existence, same as + * {@link #requireGroupConversationHitlAccess}. No-op when authorization is + * disabled. + * + * @throws ForbiddenException + * if the caller may not submit for this member. + */ + public void requireGroupHumanInputAccess(String groupId, String groupConversationId, String memberId) { + if (!ownershipValidator.isAuthEnabled()) { + return; + } + GroupConversation gc; + try { + gc = groupConversationService.readGroupConversation(groupConversationId); + } catch (ResourceNotFoundException e) { + LOGGER.debugf("Group conversation not found for human-input access check: %s", sanitize(groupConversationId)); + return; // the actual operation 404s + } catch (Exception e) { + throw new ForbiddenException("Access denied: unable to verify group conversation"); + } + if (groupId != null && !groupId.equals(gc.getGroupId())) { + LOGGER.infof("Group conversation %s does not belong to group %s", + sanitize(groupConversationId), sanitize(groupId)); + throw new jakarta.ws.rs.NotFoundException("Group conversation not found."); + } + if (ownershipValidator.isAdmin(identity)) { + return; + } + String callerId = identity != null && identity.getPrincipal() != null ? identity.getPrincipal().getName() : null; + if (callerId == null || callerId.isBlank() || memberId == null || !callerId.equals(memberId)) { + LOGGER.warnf("Human-input submission for member '%s' of %s denied for caller '%s'", + sanitize(memberId), sanitize(groupConversationId), sanitize(callerId)); + throw new ForbiddenException("Access denied: only the pending member (or an admin) may submit this turn"); + } } } diff --git a/src/main/java/ai/labs/eddi/engine/hitl/HitlCrashRecoveryObserver.java b/src/main/java/ai/labs/eddi/engine/hitl/HitlCrashRecoveryObserver.java index 42db2b9562..c582d05a6d 100644 --- a/src/main/java/ai/labs/eddi/engine/hitl/HitlCrashRecoveryObserver.java +++ b/src/main/java/ai/labs/eddi/engine/hitl/HitlCrashRecoveryObserver.java @@ -135,7 +135,9 @@ void runRecovery() { LOGGER.info("HITL crash recovery running..."); int rearmedRegular = repairRegularPaused(); int recoveredInProgress = recoverRegularInProgress(); - int rearmedGroup = repairGroupPaused(); + // Two independent sweeps: a failing AWAITING_APPROVAL query must not + // silently skip the AWAITING_HUMAN_INPUT re-arm (I6), or vice versa. + int rearmedGroup = repairGroupPaused() + repairGroupHumanPaused(); if (rearmedRegular > 0 || recoveredInProgress > 0 || rearmedGroup > 0) { LOGGER.warnf("HITL crash recovery: re-armed %d regular + %d group timeout schedule(s), " @@ -384,6 +386,59 @@ private int repairGroupPaused() { } } + /** + * I6: re-arms the turn timeout of discussions waiting on a HUMAN member. The + * SKIP_TURN/ABORT policy is bookmarked on the pending record (it is not a + * {@code HitlTimeoutPolicy}), the duration on the shared bookmark field, and + * the schedule's surface routes the fire to the human-turn handler. + */ + private int repairGroupHumanPaused() { + try { + List pausedGcs = groupConversationStore.findByState(GroupConversationState.AWAITING_HUMAN_INPUT); + int count = 0; + for (GroupConversation gc : pausedGcs) { + try { + var pending = gc.getPendingHumanInput(); + if (pending == null || gc.getHitlApprovalTimeout() == null || gc.getHitlApprovalTimeout().isBlank()) { + continue; // wait-indefinitely turn, or a half-written pause — nothing to arm + } + String groupConversationId = gc.getId(); + Instant scannedPausedAt = gc.getPausedAt(); + if (rearmSchedule(HitlSchedules.groupTimeoutScheduleName(groupConversationId), + HitlSchedules.SURFACE_GROUP_HUMAN, groupConversationId, + null, pending.onTimeout() != null ? pending.onTimeout() : "SKIP_TURN", + gc.getHitlApprovalTimeout(), scannedPausedAt, + () -> groupStillHumanPaused(groupConversationId, scannedPausedAt))) { + count++; + } + } catch (Exception e) { + LOGGER.warnf("Failed to repair human-paused group conversation %s: %s", gc.getId(), e.getMessage()); + } + } + return count; + } catch (Exception e) { + LOGGER.warnf("Error during group human-pause repair: %s", e.getMessage()); + return 0; + } + } + + /** {@code groupStillPaused}'s I6 twin — same pause-identity re-check. */ + private boolean groupStillHumanPaused(String groupConversationId, Instant scannedPausedAt) { + try { + var current = groupConversationStore.read(groupConversationId); + if (current == null || current.getState() != GroupConversationState.AWAITING_HUMAN_INPUT) { + return false; + } + if (scannedPausedAt == null) { + return true; + } + return scannedPausedAt.equals(current.getPausedAt()); + } catch (Exception e) { + LOGGER.debugf("Human-pause re-check failed for %s: %s", groupConversationId, e.getMessage()); + return true; // keep the schedule; the handler no-ops on a non-paused state + } + } + /** * Idempotently replaces the one-shot HITL timeout schedule: deletes any * existing schedule of that name and creates a fresh one at the original due @@ -399,6 +454,18 @@ private int repairGroupPaused() { private boolean rearmSchedule(String scheduleName, String surface, String conversationId, String agentId, HitlTimeoutPolicy policy, String approvalTimeout, Instant pausedAt, java.util.function.BooleanSupplier stillPaused) { + return rearmSchedule(scheduleName, surface, conversationId, agentId, policy.name(), approvalTimeout, + pausedAt, stillPaused); + } + + /** + * String-policy variant (I6): human-turn schedules carry {@code OnHumanTimeout} + * names (SKIP_TURN/ABORT), which are not {@code HitlTimeoutPolicy} values — the + * fire handler branches on the surface before parsing. + */ + private boolean rearmSchedule(String scheduleName, String surface, String conversationId, + String agentId, String policyName, String approvalTimeout, + Instant pausedAt, java.util.function.BooleanSupplier stillPaused) { if (approvalTimeout == null || approvalTimeout.isBlank() || pausedAt == null) { LOGGER.warnf("Cannot re-arm HITL timeout for %s: missing approvalTimeout/pausedAt in bookmark", conversationId); @@ -431,7 +498,7 @@ private boolean rearmSchedule(String scheduleName, String surface, String conver schedule.setCreatedAt(Instant.now()); schedule.setMetadata(Map.of( HitlSchedules.METADATA_TYPE_KEY, HitlSchedules.METADATA_TYPE_TIMEOUT, - HitlSchedules.METADATA_POLICY_KEY, policy.name(), + HitlSchedules.METADATA_POLICY_KEY, policyName, HitlSchedules.METADATA_SURFACE_KEY, surface, HitlSchedules.METADATA_CONVERSATION_ID_KEY, conversationId)); scheduleStore.createSchedule(schedule); @@ -453,7 +520,7 @@ private boolean rearmSchedule(String scheduleName, String surface, String conver } LOGGER.infof("Re-armed HITL timeout for %s (%s) at %s (policy: %s)", - conversationId, surface, fireAt, policy); + conversationId, surface, fireAt, policyName); return true; } catch (Exception e) { LOGGER.warnf("Failed to re-arm HITL timeout for %s: %s", conversationId, e.getMessage()); diff --git a/src/main/java/ai/labs/eddi/engine/hitl/HitlSchedules.java b/src/main/java/ai/labs/eddi/engine/hitl/HitlSchedules.java index 9a3ae30b3a..fe39da843d 100644 --- a/src/main/java/ai/labs/eddi/engine/hitl/HitlSchedules.java +++ b/src/main/java/ai/labs/eddi/engine/hitl/HitlSchedules.java @@ -32,6 +32,13 @@ private HitlSchedules() { public static final String SURFACE_REGULAR = "regular"; public static final String SURFACE_GROUP = "group"; + /** + * A HUMAN group member's turn timeout (I6). Its {@link #METADATA_POLICY_KEY} + * carries {@code OnHumanTimeout} names (SKIP_TURN/ABORT), NOT a + * {@code HitlTimeoutPolicy} — the fire handler must branch on this surface + * BEFORE parsing the policy. + */ + public static final String SURFACE_GROUP_HUMAN = "group-human"; private static final String NAME_PREFIX_REGULAR = "hitl-timeout-"; private static final String NAME_PREFIX_GROUP = "hitl-timeout-group-"; diff --git a/src/main/java/ai/labs/eddi/engine/internal/ConversationSchemaMigrations.java b/src/main/java/ai/labs/eddi/engine/internal/ConversationSchemaMigrations.java index 4ec900fc99..e6105b2d60 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/ConversationSchemaMigrations.java +++ b/src/main/java/ai/labs/eddi/engine/internal/ConversationSchemaMigrations.java @@ -33,8 +33,11 @@ private ConversationSchemaMigrations() { /** * Migration functions, keyed by the version they upgrade from. Empty - * today for the same reason as the group side's registry: version {@code 1} is - * the first that has ever existed. See + * today: on this surface version {@code 1} really is the only version that has + * ever existed. When bumping + * {@link ConversationMemorySnapshot#CURRENT_SCHEMA_VERSION} for the first time, + * note that key-less stored documents claim + * {@link ConversationMemorySnapshot#LEGACY_SCHEMA_VERSION} — see * {@code GroupConversationSchemaMigrations#MIGRATIONS}'s Javadoc. */ private static final Map> MIGRATIONS = Map.of(); diff --git a/src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java b/src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java index a999ec46e0..1fc0784c35 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java +++ b/src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java @@ -14,6 +14,7 @@ import ai.labs.eddi.configs.groups.IAgentGroupStore; import ai.labs.eddi.configs.groups.IGroupConversationStore; +import ai.labs.eddi.configs.groups.ISharedArtifactStore; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.ContextScope; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.DiscussionPhase; @@ -26,6 +27,7 @@ import ai.labs.eddi.configs.hitl.HitlTimeoutPolicy; import ai.labs.eddi.configs.groups.model.DiscussionStylePresets; import ai.labs.eddi.configs.groups.model.GroupConversation; +import ai.labs.eddi.configs.groups.model.GroupConversation.DecisionType; import ai.labs.eddi.configs.groups.model.GroupConversation.GroupConversationState; import ai.labs.eddi.configs.groups.model.GroupConversation.TranscriptEntry; import ai.labs.eddi.configs.groups.model.GroupConversation.TranscriptEntryType; @@ -36,13 +38,18 @@ import ai.labs.eddi.engine.api.IGroupConversationService; import ai.labs.eddi.engine.attachments.IAttachmentStore; import ai.labs.eddi.engine.internal.groups.DebateVerdictParser; +import ai.labs.eddi.engine.internal.groups.FacilitatorEngine; import ai.labs.eddi.engine.internal.groups.GroupAttachmentBinder; +import ai.labs.eddi.configs.properties.IUserMemoryStore; import ai.labs.eddi.engine.internal.groups.GroupContextBuilder; +import ai.labs.eddi.engine.internal.groups.GroupCostLedger; import ai.labs.eddi.engine.internal.groups.GroupHitlCoordinator; +import ai.labs.eddi.engine.internal.groups.RetroEngine; import ai.labs.eddi.engine.internal.groups.LiveDiscussionRegistry; import ai.labs.eddi.engine.internal.groups.GroupLifecycleOps; import ai.labs.eddi.engine.internal.groups.GroupSigningGuard; import ai.labs.eddi.engine.internal.groups.MemberTurnExecutor; +import ai.labs.eddi.engine.internal.groups.NegotiationEngine; import ai.labs.eddi.engine.internal.groups.PhaseExecutionEngine; import ai.labs.eddi.engine.internal.groups.PhaseOutcome; import ai.labs.eddi.engine.internal.groups.TaskForceEngine; @@ -51,6 +58,7 @@ import ai.labs.eddi.engine.model.Deployment.Environment; import ai.labs.eddi.engine.runtime.IAgentFactory; import ai.labs.eddi.engine.runtime.internal.GracefulShutdownService; +import ai.labs.eddi.modules.llm.impl.SummarizationService; import ai.labs.eddi.modules.templating.ITemplatingEngine; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; @@ -178,6 +186,7 @@ public class GroupConversationService implements IGroupConversationService { private final GroupSigningGuard signingGuard; private final MemberTurnExecutor memberTurnExecutor; private final PhaseExecutionEngine phaseExecutionEngine; + private final FacilitatorEngine facilitatorEngine; private final TaskForceEngine taskForceEngine; private final GroupHitlCoordinator hitlCoordinator; @@ -208,6 +217,31 @@ public class GroupConversationService implements IGroupConversationService { @Inject LiveDiscussionRegistry liveDiscussionRegistry; + /** + * I8's lesson store. Same field-injection pattern and null-safety as + * {@link #attachmentStore} — {@code null} in direct-construction unit tests, + * where a RETRO phase runs but persists nothing (warned, never failed). + */ + @Inject + IUserMemoryStore userMemoryStore; + + /** + * I17's artifact store. Same field-injection pattern and null-safety as + * {@link #attachmentStore} — {@code null} in direct-construction unit tests, + * where reads simply carry no artifacts and lifecycle cascades no-op. + */ + @Inject + ISharedArtifactStore sharedArtifactStore; + + /** + * Shared LLM summarization for I9 transcript windowing. Same field-injection + * pattern and null-safety as {@link #attachmentStore} — {@code null} in the + * direct-construction unit tests, where an enabled window falls back to the + * plain truncation marker instead of summarizing. + */ + @Inject + SummarizationService summarizationService; + // In-node fast-fail guard for concurrent post-discussion operations // (follow-up, continue, close) on the same conversation: a second // operation on the same gcId is rejected rather than queued. The Set is @@ -289,6 +323,9 @@ public GroupConversationService(IAgentGroupStore groupStore, IGroupConversationS this.memberTurnExecutor = new MemberTurnExecutor(conversationService, agentFactory, signingGuard, contextBuilder, this, counterGroupMemberPauseSkipped, DEFAULT_AGENT_TIMEOUT_SECONDS, DEFAULT_MAX_RETRIES); this.phaseExecutionEngine = new PhaseExecutionEngine(memberTurnExecutor, contextBuilder, executorService, callerIdentityContext); + // I12: the deployment store is field-injected (RECRUIT's deployed-and-ready + // check), so the engine takes a supplier that reads it lazily. + this.facilitatorEngine = new FacilitatorEngine(memberTurnExecutor, () -> deploymentStore, auditLedgerService, meterRegistry); this.taskForceEngine = new TaskForceEngine(memberTurnExecutor, templatingEngine, jsonSerialization, executorService, callerIdentityContext, activeTokens, DEFAULT_AGENT_TIMEOUT_SECONDS, MEMBER_TURN_CANCEL_DRAIN_SECONDS); // Constructed last, same reasoning as memberTurnExecutor above: needs `this` @@ -495,6 +532,63 @@ public GroupConversation startAndDiscussAsync(String groupId, String question, S return gc; } + /** + * Starts a cadence-driven discussion (I13): {@code startAndDiscussAsync} with + * two overrides a scheduled backlog run needs — the pulled backlog tasks + * replace the config's pre-configured task list (a runtime copy; the stored + * config is never written), and the cadence's dollar ceiling rides the + * inherited-ceiling slot so {@code effectiveCostCeiling} takes the tighter of + * it and the group's own {@code maxCostPerDiscussion}. + *

    + * The config instance mutated here is this call's own fresh read from the store + * — deserialized per read, shared with nothing. + */ + public GroupConversation startCadenceDiscussionAsync(String groupId, String question, String userId, + List injectedTasks, + Double maxCostPerRun, GroupDiscussionEventListener listener) + throws GroupDiscussionException, IResourceStore.ResourceStoreException, IResourceStore.ResourceNotFoundException { + if (groupId == null) { + throw new IllegalArgumentException("groupId must not be null"); + } + rejectIfShuttingDown(); + + IResourceStore.IResourceId currentGroupId = groupStore.getCurrentResourceId(groupId); + if (currentGroupId == null) { + throw new IResourceStore.ResourceNotFoundException("Group not found."); + } + AgentGroupConfiguration config = groupStore.read(groupId, currentGroupId.getVersion()); + if (config == null) { + throw new IResourceStore.ResourceNotFoundException("Group configuration not found."); + } + if (injectedTasks != null && !injectedTasks.isEmpty()) { + config.setTasks(List.copyOf(injectedTasks)); + } + + List phases = resolvePhases(config); + if (phases.isEmpty()) { + throw new GroupDiscussionException("No discussion phases are defined for this group."); + } + + GroupConversation gc = createGroupConversation(groupId, question, userId, 0); + gc.setInheritedCostCeiling(maxCostPerRun); + activeTokens.put(gc.getId(), new DiscussionControlToken()); + final var discussionCaller = callerIdentityContext.captureOrCurrent(); + try { + executorService.submit(callerIdentityContext.withIdentity(discussionCaller, () -> { + try { + executeDiscussion(gc, config, phases, question, listener, 0); + } catch (Exception e) { + LOGGER.errorf("Cadence group discussion failed for %s: %s", LogSanitizer.sanitize(groupId), e.getMessage()); + } + })); + } catch (RuntimeException e) { + activeTokens.remove(gc.getId()); + failConversation(gc); + throw new GroupDiscussionException("Failed to start cadence discussion: " + e.getMessage(), e); + } + return gc; + } + /** * Materialize discussion attachments and bind them to the group conversation. * Delegates to {@link GroupAttachmentBinder}; see its Javadoc for behavior. @@ -566,6 +660,17 @@ public GroupConversation executeDiscussion(GroupConversation gc, AgentGroupConfi resolvedProtocol.onCostExceeded()); int maxTurns = protocol.maxTurns() > 0 ? protocol.maxTurns() : 50; + // I12: the loop iterates a runtime COPY of the phase list. A facilitator + // move may diverge it mid-run (CALL_VOTE inserts a phase, EXTEND_PHASE bumps + // a repeat count); on divergence the copy is persisted to gc.runtimePhases, + // which every resume path then prefers over the config's phases — a bookmark + // taken against the diverged list must never be re-indexed into the + // config's. Preferring an already-persisted runtime list here is the resume + // half of that same rule. + List phaseList = gc.getRuntimePhases() != null && !gc.getRuntimePhases().isEmpty() + ? new ArrayList<>(gc.getRuntimePhases()) + : new ArrayList<>(phases); + // Store the group's DynamicAgentConfig on the GC so executeAgentTurn() // can pass it to member agents via context variables, allowing // AgentOrchestrator to enforce group-level guardrails on dynamic tools. @@ -607,13 +712,13 @@ public GroupConversation executeDiscussion(GroupConversation gc, AgentGroupConfi if (startPhaseIndex == 0 && listener != null) { if (gc.getRound() <= 1) { listener.onGroupStart(new GroupConversationEventSink.GroupStartEvent(gc.getId(), gc.getGroupId(), question, - config.getStyle() != null ? config.getStyle().name() : "ROUND_TABLE", phases.size(), + config.getStyle() != null ? config.getStyle().name() : "ROUND_TABLE", phaseList.size(), config.getMembers() != null ? config.getMembers().stream().map(GroupMember::agentId).toList() : List.of())); } else { listener.onRoundStart(new GroupConversationEventSink.RoundStartEvent( - gc.getId(), gc.getRound(), question, phases.size())); + gc.getId(), gc.getRound(), question, phaseList.size())); } } @@ -648,8 +753,8 @@ public GroupConversation executeDiscussion(GroupConversation gc, AgentGroupConfi } // Execute each phase - for (int phaseIdx = startPhaseIndex; phaseIdx < phases.size(); phaseIdx++) { - DiscussionPhase phase = phases.get(phaseIdx); + for (int phaseIdx = startPhaseIndex; phaseIdx < phaseList.size(); phaseIdx++) { + DiscussionPhase phase = phaseList.get(phaseIdx); // NEW-3: Check control token at top of phase loop var token = activeTokens.get(gc.getId()); @@ -672,12 +777,35 @@ public GroupConversation executeDiscussion(GroupConversation gc, AgentGroupConfi continue; } + // I11: the one skip condition — a phase marked skipIf=AGREEMENT_REACHED + // (the negotiation's arbitration) is only needed when bargaining + // failed; a reached agreement makes it provably redundant. Checked + // deterministically against the typed decision, never inferred. + if (phase.skipIf() == AgentGroupConfiguration.PhaseSkipCondition.AGREEMENT_REACHED + && gc.getDecision() != null && gc.getDecision().type() == DecisionType.AGREEMENT) { + LOGGER.infof("Group %s: skipping phase '%s' — agreement already reached", + LogSanitizer.sanitize(gc.getGroupId()), LogSanitizer.sanitize(phase.name())); + continue; + } + // I2: the previous repeat's contributions, the baseline the // convergence judge compares against. Scoped to this phase — a // comparison across two different phases would be meaningless. List previousRepeatEntries = null; - for (int repeat = 0; repeat < Math.max(phase.repeats(), 1); repeat++) { + // I6: a mid-phase bookmark names the repeat the pause landed on — + // start THERE instead of replaying every earlier repeat (each + // replayed repeat is a full round of duplicate turns and spend). + // Peeked, not consumed: the loop's own read-and-clear below still + // owns the speaker offset. Clamped so a bookmark from a config + // whose repeats shrank cannot skip the phase entirely. + int startRepeat = 0; + GroupConversation.ResumePoint repeatBookmark = gc.getResumePoint(); + if (repeatBookmark != null && repeatBookmark.phaseIdx() == phaseIdx) { + startRepeat = Math.min(Math.max(repeatBookmark.repeatIdx(), 0), Math.max(phase.repeats(), 1) - 1); + } + + for (int repeat = startRepeat; repeat < Math.max(phase.repeats(), 1); repeat++) { // --- maxTurns safety cap --- if (turnCounter.get() >= maxTurns) { @@ -718,34 +846,85 @@ public GroupConversation executeDiscussion(GroupConversation gc, AgentGroupConfi // what guarantees this matches on the very first (phaseIdx, repeat) // this leg visits, before executeDiscussion is ever called. int startSpeakerIdx = 0; + Integer parallelHumanResumeIdx = null; GroupConversation.ResumePoint resumePoint = gc.getResumePoint(); if (resumePoint != null) { gc.setResumePoint(null); if (resumePoint.phaseIdx() == phaseIdx && resumePoint.repeatIdx() == repeat) { - startSpeakerIdx = resumePoint.speakerIdx(); + // I6: a parallel human bookmark indexes the phase's + // human-only sublist and means the agent fan-out already + // ran — routed to executeParallelPhase below instead of + // the sequential offset. + if (GroupConversation.RESUME_KIND_HUMAN_TURN_PARALLEL.equals(resumePoint.pauseKind())) { + parallelHumanResumeIdx = resumePoint.speakerIdx(); + } else { + startSpeakerIdx = resumePoint.speakerIdx(); + } } } + // I9: extend the rolling window summary this repeat's FULL/ + // ANONYMOUS-scope turns will render with. At the boundary, never + // per member turn — that per-turn re-feeding is the quadratic + // cost the window exists to stop. A no-op unless the window is + // enabled and the transcript outgrew it since the last extension. + // Ceiling-gated like the convergence judge and the dissent round: + // summarization is optional spend, and a discussion that already + // blew its budget must not pay for one more LLM call the next + // executor's own gate is about to stop anyway. + if (!GroupCostLedger.wouldExceedCeiling(gc, protocol)) { + contextBuilder.updateWindowSummary(gc, phase, config.getContextWindow(), summarizationService); + } + // I2: mark where this repeat's entries begin. TranscriptEntry // carries phaseIndex but no repeat index, so with repeats > 1 the // only way to say "what this repeat produced" is by position. + // I6: a human-turn pause landed MID-repeat — the persisted base + // (taken when the pause committed) wins over a recompute that + // would only see post-pause entries. Read-and-clear with the same + // one-shot discipline as the speaker bookmark above. int transcriptSizeBeforeRepeat = gc.getTranscript().size(); + if (gc.getPausedRepeatSliceBase() >= 0) { + transcriptSizeBeforeRepeat = Math.min(gc.getPausedRepeatSliceBase(), transcriptSizeBeforeRepeat); + gc.setPausedRepeatSliceBase(-1); + } // --- Task-oriented phase routing --- - if (phase.type() == PhaseType.PLAN || phase.type() == PhaseType.EXECUTE || phase.type() == PhaseType.VERIFY) { - executeTaskPhase(gc, config, speakers, phase, protocol, question, phaseIdx, listener, turnCounter, maxTurns); - } else if (phase.targetEachPeer()) { - phaseExecutionEngine.executePeerTargetedPhase(gc, config, speakers, phase, protocol, question, phaseIdx, listener, - turnCounter, - maxTurns); - } else if (phase.turnOrder() == TurnOrder.PARALLEL) { - // F2: PARALLEL never honors a speaker offset — see - // GroupConversation.ResumePoint's Javadoc for why a parallel - // resume always re-runs its whole fan-out instead. - executeParallelPhase(gc, config, speakers, phase, protocol, question, phaseIdx, listener, turnCounter, maxTurns); - } else { - phaseExecutionEngine.executeSequentialPhase(gc, config, speakers, phase, protocol, question, phaseIdx, listener, turnCounter, - maxTurns, startSpeakerIdx); + // I6: the dispatch is wrapped so a HUMAN member's turn — surfaced + // by the executors as a HumanTurnRequired signal — commits an + // AWAITING_HUMAN_INPUT pause and ends this leg, exactly like the + // commitPause call sites below end theirs. + try { + if (phase.type() == PhaseType.PLAN || phase.type() == PhaseType.EXECUTE || phase.type() == PhaseType.VERIFY) { + executeTaskPhase(gc, config, speakers, phase, protocol, question, phaseIdx, listener, turnCounter, maxTurns); + } else if (phase.targetEachPeer()) { + phaseExecutionEngine.executePeerTargetedPhase(gc, config, speakers, phase, protocol, question, phaseIdx, listener, + turnCounter, + maxTurns); + } else if (phase.turnOrder() == TurnOrder.PARALLEL) { + // F2: PARALLEL never honors a speaker offset — with I6's + // one carve-out: a HUMAN_TURN_PARALLEL bookmark resumes + // the phase's human tail instead of re-running the + // fan-out; see GroupConversation.ResumePoint's Javadoc. + executeParallelPhase(gc, config, speakers, phase, protocol, question, phaseIdx, listener, turnCounter, maxTurns, + parallelHumanResumeIdx); + } else { + phaseExecutionEngine.executeSequentialPhase(gc, config, speakers, phase, protocol, question, phaseIdx, listener, + turnCounter, + maxTurns, startSpeakerIdx); + } + } catch (PhaseExecutionEngine.HumanTurnRequired humanTurn) { + // I6 slice-base fix: the pause lands MID-repeat, after other + // speakers appended this repeat's entries. Persist where the + // repeat began so the resumed leg's convergence slice (and + // every later consumer of repeatEntries) still covers the + // pre-pause contributions instead of only what follows. + gc.setPausedRepeatSliceBase(transcriptSizeBeforeRepeat); + hitlCoordinator.commitHumanTurnPause(gc, phaseIdx, phase, repeat, humanTurn, + turnCounter.get() + 1, contextBuilder.mapPhaseToEntryType(phase.type()).name(), + listener, config); + convertPauseToCancelIfSignalled(gc, listener); + return gc; } // I1: a phase executor hit the cost ceiling and stopped scheduling @@ -816,6 +995,80 @@ public GroupConversation executeDiscussion(GroupConversation gc, AgentGroupConfi repeatEntries, previousRepeatEntries, listener, turnCounter, maxTurns); } + // I11: fold this repeat's PROPOSAL/BARGAIN turns into the + // negotiation state, then test for unanimous acceptance — which + // ends the phase's repeats early through the same outcome + // plumbing convergence uses. Applied BEFORE the persist below so + // the table and the turns that produced it share one write. + if (phase.type() == PhaseType.PROPOSAL || phase.type() == PhaseType.BARGAIN) { + NegotiationEngine.applyRepeat(gc, repeatEntries, transcriptSizeBeforeRepeat, repeat); + if (phase.type() == PhaseType.BARGAIN + && NegotiationEngine.checkAndRecordAgreement(gc, speakers, config.getModeratorAgentId(), phase.name())) { + outcome = PhaseOutcome.endRepeats("Unanimous acceptance — agreement reached"); + if (listener != null) { + listener.onDecisionReached( + new GroupConversationEventSink.DecisionReachedEvent(gc.getDecision())); + } + } + } + + // I12: EACH_REPEAT facilitator checkpoint — the CONSULT runs here, + // after convergence but BEFORE the last-repeat decision block, and + // its effects are split by kind (final-review finding, both halves): + // • END_PHASE folds into `outcome` so the block below sees a real + // phase end and still tallies ballots / records the verdict / + // runs the dissent round — the plain `break` it used to take + // skipped all of that, leaving cast VOTE ballots untallied. + // • EXTEND_PHASE applies now, before `lastRepeat` is computed, so + // extending a final repeat DEFERS the decision block instead of + // re-running one that already fired (duplicate dissent rounds, + // decision_reached twice, tally overwritten). + // • INSERT_VOTE and ESCALATE are stashed and applied after the + // block — an escalation at a final repeat must not skip the + // repeat's own decisions on its way out. + FacilitatorEngine.FacilitatorAction deferredFacilitatorAction = null; + boolean facilitatorMidPhaseResume = false; + if (config.getFacilitator() != null && config.getFacilitator().enabled() + && config.getFacilitator().checkAfter() == AgentGroupConfiguration.FacilitatorCheckpoint.EACH_REPEAT + && !costCeilingSynthesizeNow) { + boolean moreRepeats = repeat < Math.max(phase.repeats(), 1) - 1; + boolean midPhaseResume = moreRepeats && outcome.isContinue(); + boolean escalationTarget = midPhaseResume || phaseIdx + 1 < phaseList.size(); + var facilitatorCtx = new FacilitatorEngine.CheckpointContext( + phaseIdx, repeat, true, moreRepeats, !outcome.isContinue(), escalationTarget); + var facilitatorAction = facilitatorEngine.checkpoint(gc, config, phase, facilitatorCtx, + protocol, question, listener, turnCounter, maxTurns); + switch (facilitatorAction.kind()) { + case END_PHASE -> { + LOGGER.infof("Facilitator ended phase '%s' of group %s after repeat %d", + LogSanitizer.sanitize(phase.name()), LogSanitizer.sanitize(gc.getGroupId()), repeat); + outcome = PhaseOutcome.endRepeats("Facilitator ended the phase"); + } + case EXTEND_PHASE -> { + // The extension lives in the RUNTIME phase list (a new + // record with repeats+1), not a loose counter — so the + // loop bound, the resume clamp and the drift check all + // read the same extended value after a pause. + // Full canonical ctor — a compat overload here silently + // dropped skipIf, so extending an arbitration-adjacent + // phase erased its AGREEMENT_REACHED skip condition. + phase = new DiscussionPhase(phase.name(), phase.type(), phase.participants(), + phase.turnOrder(), phase.contextScope(), phase.targetEachPeer(), + phase.inputTemplate(), Math.max(phase.repeats(), 1) + 1, phase.requiresApproval(), + phase.convergence(), phase.allowAbstention(), phase.voteConfig(), phase.skipIf()); + phaseList.set(phaseIdx, phase); + persistRuntimePhases(gc, phaseList); + } + case INSERT_VOTE, ESCALATE -> { + deferredFacilitatorAction = facilitatorAction; + facilitatorMidPhaseResume = midPhaseResume; + } + case NONE -> { + // no-op; any rejection entry rides the next persist + } + } + } + // I4: the minority report. Placed AFTER the convergence slice above // and BEFORE the persist below — after, so the DISSENT entries do // not land inside repeatEntries where the convergence judge would @@ -843,11 +1096,47 @@ public GroupConversation executeDiscussion(GroupConversation gc, AgentGroupConfi // verdict landing on top of a dissent-only record. Same // last-repeat reasoning as below — a draft judgment is not the // decision. - phaseExecutionEngine.recordDebateVerdict(gc, config, phase, phaseIdx, speakers); + boolean verdictRecorded = phaseExecutionEngine.recordDebateVerdict(gc, config, phase, phaseIdx, speakers); + // I11: an arbitration phase (skipIf=AGREEMENT_REACHED) that + // RAN means bargaining failed — its conclusion is the + // discussion's VERDICT. Never overwrites an existing decision; + // `arbitrated` is true only when THIS call set one, so the + // event below cannot re-announce an earlier phase's decision. + boolean arbitrated = false; + if (phase.skipIf() == AgentGroupConfiguration.PhaseSkipCondition.AGREEMENT_REACHED) { + boolean hadDecision = gc.getDecision() != null; + NegotiationEngine.recordArbitration(gc, repeatEntries, phase.name()); + arbitrated = !hadDecision && gc.getDecision() != null; + } if (config.isRecordDissents()) { phaseExecutionEngine.runDissentRound(gc, config, phase, protocol, phaseIdx, speakers, listener, turnCounter, maxTurns); } + // I14 fold-in (§4 gap): decision_reached finally has a producer. + // Fired AFTER the dissent round so the event's record carries the + // merged dissents, and only for a real verdict — a prose-only + // synthesis set no record worth announcing. Covers I11's + // arbitration verdicts through the same late firing. + if (verdictRecorded || arbitrated) { + phaseExecutionEngine.fireDecisionReached(gc, listener); + } + } + + // I14: a completed VOTE phase tallies into a DecisionRecord (and + // fires decision_reached itself). Last repeat only, same reasoning + // as the verdict above — a re-balloting round's draft tally is not + // the decision. + if (phase.type() == PhaseType.VOTE && lastRepeat) { + phaseExecutionEngine.recordVoteDecision(gc, config, phase, protocol, phaseIdx, repeatEntries, speakers, listener, + turnCounter, maxTurns); + } + + // I8: a completed RETRO phase harvests its lessons into + // team-owned group memory. Last repeat only — a draft retro is + // not the retrospective — and BEFORE the persist below, so a + // crash cannot lose lessons a stored document claims were taken. + if (phase.type() == PhaseType.RETRO && lastRepeat) { + RetroEngine.harvest(gc, config.getRetroConfig(), repeatEntries, userMemoryStore, phase.name(), listener); } gc.setLastModified(Instant.now()); @@ -857,6 +1146,63 @@ public GroupConversation executeDiscussion(GroupConversation gc, AgentGroupConfi listener.onPhaseComplete(new GroupConversationEventSink.PhaseCompleteEvent(phaseIdx, phase.name())); } + // I12: apply the checkpoint's stashed structural effects now that + // the repeat's own decisions are recorded and persisted. + if (deferredFacilitatorAction != null) { + switch (deferredFacilitatorAction.kind()) { + case INSERT_VOTE -> { + phaseList.add(phaseIdx + 1, deferredFacilitatorAction.insertPhase()); + persistRuntimePhases(gc, phaseList); + } + case ESCALATE -> { + // CRITICAL review finding: a BOUNDARY escalation + // (midPhaseResume=false) returns from inside the loop + // BEFORE the phase-boundary HITL gate below — silently + // skipping a requiresApproval phase's mandatory human + // approval (and the TASK-granularity awaiting-task + // pause), with the escalation answerer holding no + // REJECT path. The approval gate supersedes the + // facilitator: the approver is about to be in the loop + // anyway and can read the facilitator's question in + // the transcript entry recorded here. Mid-phase + // escalations are unaffected — the phase is not + // complete, so no boundary gate is owed yet and the + // resumed leg still reaches it at phase end. + boolean boundaryGateWillPause = !facilitatorMidPhaseResume + && phase.requiresApproval() && !costCeilingSynthesizeNow + && (!(taskLevelHitl && phase.type() == PhaseType.EXECUTE) + || (gc.getTaskList() != null + && (gc.getTaskList().hasAwaitingApproval() + || !gc.getTaskList().findExecutableTasks().isEmpty()))); + if (boundaryGateWillPause) { + var suppressed = deferredFacilitatorAction.escalation(); + gc.getTranscript().add(new TranscriptEntry( + config.getFacilitator().agentId(), "Facilitator", + "Facilitator escalation to " + suppressed.principalId() + + " suppressed — this phase requires human approval, which takes " + + "precedence. The facilitator's question for the approver: " + + suppressed.question(), + phaseIdx, phase.name(), TranscriptEntryType.FACILITATION, Instant.now(), + null, null)); + LOGGER.infof("Facilitator escalation for group %s suppressed at phase %d — the " + + "phase's own approval gate takes precedence", gc.getGroupId(), phaseIdx); + // fall through to the HITL gate below + } else { + int resumePhaseIdx = facilitatorMidPhaseResume ? phaseIdx : phaseIdx + 1; + int resumeRepeatIdx = facilitatorMidPhaseResume ? repeat + 1 : 0; + hitlCoordinator.commitFacilitatorEscalationPause(gc, resumePhaseIdx, resumeRepeatIdx, + phaseList, deferredFacilitatorAction.escalation(), turnCounter.get() + 1, listener, config); + convertPauseToCancelIfSignalled(gc, listener); + return gc; + } + } + default -> { + // END_PHASE/EXTEND_PHASE were applied before the + // decision block; NONE never lands here. + } + } + } + if (!outcome.isContinue()) { LOGGER.infof("Phase '%s' of group %s ended early after repeat %d: %s", phase.name(), gc.getGroupId(), repeat, outcome.reason()); @@ -946,6 +1292,39 @@ public GroupConversation executeDiscussion(GroupConversation gc, AgentGroupConfi break; } + // I12: EACH_PHASE facilitator checkpoint — the default cadence. AFTER + // the HITL gate on purpose: a phase that pauses for approval must + // never have its approval silently skipped because a facilitator + // escalated first (the reverse — a facilitator missing one checkpoint + // because the phase paused — is the harmless direction). END_PHASE and + // EXTEND_PHASE are invalid at a phase boundary; the engine rejects + // them via the context (and save-time validation refuses the combo). + if (config.getFacilitator() != null && config.getFacilitator().enabled() + && config.getFacilitator().checkAfter() == AgentGroupConfiguration.FacilitatorCheckpoint.EACH_PHASE + && !costCeilingSynthesizeNow && !endDiscussionEarly) { + var facilitatorCtx = new FacilitatorEngine.CheckpointContext( + phaseIdx, Math.max(phase.repeats(), 1) - 1, false, false, false, + phaseIdx + 1 < phaseList.size()); + var facilitatorAction = facilitatorEngine.checkpoint(gc, config, phase, facilitatorCtx, + protocol, question, listener, turnCounter, maxTurns); + switch (facilitatorAction.kind()) { + case INSERT_VOTE -> { + phaseList.add(phaseIdx + 1, facilitatorAction.insertPhase()); + persistRuntimePhases(gc, phaseList); + } + case ESCALATE -> { + hitlCoordinator.commitFacilitatorEscalationPause(gc, phaseIdx + 1, 0, + phaseList, facilitatorAction.escalation(), turnCounter.get() + 1, listener, config); + convertPauseToCancelIfSignalled(gc, listener); + return gc; + } + default -> { + // NONE (incl. every rejection) — nothing to apply; + // END_PHASE/EXTEND_PHASE cannot reach here (rejected above) + } + } + } + // I2: END_DISCUSSION ends the phase loop itself, unlike END_REPEATS // which only ended the repeat loop above. Placed after the HITL gate so // an approval that was already due is still honored. @@ -996,8 +1375,9 @@ public GroupConversation executeDiscussion(GroupConversation gc, AgentGroupConfi } } - // Don't overwrite AWAITING_APPROVAL with COMPLETED - if (gc.getState() == GroupConversationState.AWAITING_APPROVAL) { + // Don't overwrite AWAITING_APPROVAL (or a human-turn pause) with COMPLETED + if (gc.getState() == GroupConversationState.AWAITING_APPROVAL + || gc.getState() == GroupConversationState.AWAITING_HUMAN_INPUT) { return gc; } // #27/#45: complete with a CAS on the running state this leg believes it @@ -1009,6 +1389,11 @@ public GroupConversation executeDiscussion(GroupConversation gc, AgentGroupConfi gc.setState(GroupConversationState.COMPLETED); gc.setPausedTurnCount(0); // Clear turn budget state on successful completion gc.setHitlLastPauseFingerprint(null); // #4: reset no-progress guard + // I12: a facilitator's phase-list divergence is one-off for the round — + // a continuation round starts from the config's phases again, and stale + // extension counts would misattribute under next round's indices. + gc.setRuntimePhases(null); + gc.clearFacilitatorExtensions(); gc.setLastModified(Instant.now()); try { conversationStore.updateIfState(gc, expectedRunningState); @@ -1083,6 +1468,13 @@ public GroupConversation executeDiscussion(GroupConversation gc, AgentGroupConfi throw new GroupExecutionException("Group discussion failed: " + e.getMessage(), e); } finally { timerGroupDiscussion.record(System.nanoTime() - startTime, TimeUnit.NANOSECONDS); + // I17: last announce pass for this leg. A member turn that timed out + // drained an empty queue in ITS finally, while its still-running agent + // could accept an artifact write afterwards; without this, that write's + // event is stranded until (unless) another turn runs. A write accepted + // after even this pass keeps the artifact (the store write already + // committed) — only its live event is best-effort, by design. + MemberTurnExecutor.announceArtifactChanges(gc, listener); // I1: fold this leg's spend into the lifetime gauge. Recorded as a delta // against what this leg started with, so a resumed leg (whose gc arrives // already carrying the pre-pause total) contributes only what it newly @@ -1108,9 +1500,11 @@ public GroupConversation executeDiscussion(GroupConversation gc, AgentGroupConfi // the no-op signal branch. Resume re-registers a fresh token. Re-check the // removed token so a cancel that raced this remove is not silently dropped. removeTokenAndConvertIfSignalled(gc, listener); - // Drop the incremental verification cursor once this leg ends, but keep it - // across an HITL pause so a resume continues from where it left off. - if (gc.getState() != GroupConversationState.AWAITING_APPROVAL) { + // Drop the incremental verification cursor once this leg ends, but keep + // it across ANY pause (approval or a human turn, I6) so a resume + // continues from where it left off. + if (gc.getState() != GroupConversationState.AWAITING_APPROVAL + && gc.getState() != GroupConversationState.AWAITING_HUMAN_INPUT) { signingGuard.forgetConversation(gc.getId()); } // Defer ephemeral cleanup to closeGroupConversation()/deleteGroupConversation() @@ -1188,7 +1582,30 @@ private void scheduleGroupHitlTimeout(GroupConversation gc) { @Override public GroupConversation readGroupConversation(String groupConversationId) throws IResourceStore.ResourceNotFoundException, IResourceStore.ResourceStoreException { - return lifecycleOps().readGroupConversation(groupConversationId); + GroupConversation gc = lifecycleOps().readGroupConversation(groupConversationId); + populateArtifacts(gc); + return gc; + } + + /** + * I17: attaches the discussion's shared artifacts as a read-time derived field, + * here in the service so REST's status payload and MCP's + * {@code read_group_conversation} both carry them. Best-effort — a status read + * must not fail because the artifact store hiccuped. + */ + private void populateArtifacts(GroupConversation gc) { + if (sharedArtifactStore == null || gc == null || gc.getId() == null) { + return; + } + try { + var artifacts = sharedArtifactStore.listByGroupConversationId(gc.getId()); + if (!artifacts.isEmpty()) { + gc.setArtifacts(artifacts); + } + } catch (Exception e) { + LOGGER.warnf("Could not attach shared artifacts to group conversation %s: %s", + LogSanitizer.sanitize(gc.getId()), LogSanitizer.sanitize(e.getMessage())); + } } @Override @@ -1242,7 +1659,7 @@ private void failConversation(GroupConversation gc) { private GroupLifecycleOps lifecycleOps() { return new GroupLifecycleOps(conversationStore, groupStore, conversationService, agentFactory, agentStore, - deploymentStore, operationsInProgress, activeTokens, this, + deploymentStore, sharedArtifactStore, operationsInProgress, activeTokens, this, counterGroupFollowUp, counterGroupContinue, counterGroupClose, counterGroupFailure); } @@ -1269,6 +1686,31 @@ public List resolvePhases(AgentGroupConfiguration config) { return DiscussionStylePresets.expand(style, config.getMaxRounds()); } + /** + * The phase list a RESUME must execute and drift-check against (I12): the + * conversation's persisted runtime list when a facilitator diverged it, else + * the config's phases. Every resume surface (approval resume, human-turn + * submission, timeout skip) goes through this — re-resolving the config for a + * conversation whose pause was taken against a diverged list would mis-index + * the bookmark. + */ + public List effectivePhases(GroupConversation gc, AgentGroupConfiguration config) { + List runtime = gc.getRuntimePhases(); + return runtime != null && !runtime.isEmpty() ? runtime : resolvePhases(config); + } + + /** + * Persists a facilitator-diverged phase list (I12). The write is the whole + * document, which also carries the FACILITATION entry and move counters the + * checkpoint just produced — one write, one consistent snapshot. + */ + private void persistRuntimePhases(GroupConversation gc, List phaseList) + throws IResourceStore.ResourceStoreException { + gc.setRuntimePhases(List.copyOf(phaseList)); + gc.setLastModified(Instant.now()); + conversationStore.update(gc); + } + private ProtocolConfig resolveProtocol(AgentGroupConfiguration config) { return config.getProtocol() != null ? config.getProtocol() @@ -1366,6 +1808,17 @@ public List resolveParticipants(DiscussionPhase phase, List moderatorAgentId.equals(m.agentId())).findFirst().orElse(null) + : null; + if (rosterModerator != null) { + return List.of(new GroupMember(rosterModerator.agentId(), rosterModerator.displayName(), 0, "MODERATOR", + rosterModerator.memberType())); + } return List.of(new GroupMember(moderatorAgentId, "Moderator", 0, "MODERATOR")); } @@ -1610,6 +2063,17 @@ private void executeParallelPhase(GroupConversation gc, AgentGroupConfiguration phaseExecutionEngine.executeParallelPhase(gc, config, speakers, phase, protocol, question, phaseIdx, listener, turnCounter, maxTurns); } + /** + * I6 overload: {@code humanResumeIdx} resumes a parallel phase's human tail. + */ + private void executeParallelPhase(GroupConversation gc, AgentGroupConfiguration config, List speakers, DiscussionPhase phase, + ProtocolConfig protocol, String question, int phaseIdx, GroupDiscussionEventListener listener, + java.util.concurrent.atomic.AtomicInteger turnCounter, int maxTurns, Integer humanResumeIdx) + throws GroupDiscussionException { + phaseExecutionEngine.executeParallelPhase(gc, config, speakers, phase, protocol, question, phaseIdx, listener, turnCounter, maxTurns, + humanResumeIdx); + } + // ================================================================= // Agent turn execution // ================================================================= @@ -1690,6 +2154,9 @@ private GroupConversation createGroupConversation(String groupId, String questio throws IResourceStore.ResourceStoreException { GroupConversation gc = new GroupConversation(); + // The field initialiser is the LEGACY sentinel so key-less stored documents + // read as legacy — a genuinely new document must claim current explicitly. + gc.setSchemaVersion(GroupConversation.CURRENT_SCHEMA_VERSION); gc.setGroupId(groupId); gc.setUserId(userId); gc.setState(GroupConversationState.IN_PROGRESS); @@ -1785,6 +2252,19 @@ public GroupConversation resumeDiscussion(String groupConversationId, GroupAppro return hitlCoordinator.resumeDiscussion(groupConversationId, request, listener); } + @Override + public GroupConversation submitHumanInput(String groupConversationId, String memberId, String content, String submittedBy) + throws GroupDiscussionException, IResourceStore.ResourceStoreException, + IResourceStore.ResourceNotFoundException, IResourceStore.ResourceModifiedException { + rejectIfShuttingDown(); + return hitlCoordinator.submitHumanInput(groupConversationId, memberId, content, submittedBy, null); + } + + @Override + public void skipHumanTurnOnTimeout(String groupConversationId) { + hitlCoordinator.skipHumanTurnOnTimeout(groupConversationId); + } + private void restoreGroupPause(GroupConversation gc, int phaseIndex, String phaseName, GroupConversation.HitlPauseType pauseType, Instant pausedAt, AgentGroupConfiguration configOrNull, diff --git a/src/main/java/ai/labs/eddi/engine/internal/HitlTimeoutHandler.java b/src/main/java/ai/labs/eddi/engine/internal/HitlTimeoutHandler.java index 99abdee3b2..84f6343658 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/HitlTimeoutHandler.java +++ b/src/main/java/ai/labs/eddi/engine/internal/HitlTimeoutHandler.java @@ -44,6 +44,12 @@ public void handleTimeout(Map metadata) { LOGGER.error("HITL timeout metadata missing 'policy' key"); return; } + // I6: human-turn timeouts carry OnHumanTimeout policies (SKIP_TURN/ABORT), + // not HitlTimeoutPolicy values — branch on the surface BEFORE the parse. + if (HitlSchedules.SURFACE_GROUP_HUMAN.equals(surface)) { + handleHumanTurnTimeout(metadata, policyStr); + return; + } HitlTimeoutPolicy policy; try { policy = HitlTimeoutPolicy.valueOf(policyStr); @@ -80,6 +86,30 @@ public void handleTimeout(Map metadata) { } } + /** + * I6: an expired human turn resolves per the group's {@code humanMemberConfig} + * — SKIP_TURN records a SKIPPED entry and moves on; ABORT cancels the + * discussion (the same graceful cancel the approval ABORT policy uses). + */ + private void handleHumanTurnTimeout(Map metadata, String policyStr) { + String gcId = (String) metadata.get(HitlSchedules.METADATA_CONVERSATION_ID_KEY); + try { + if ("ABORT".equals(policyStr)) { + boolean cancelled = groupConversationService.cancelDiscussion(gcId, + ai.labs.eddi.engine.lifecycle.model.ControlSignal.CANCEL_GRACEFUL); + LOGGER.infof("Human-turn timeout ABORT for group conversation %s%s", gcId, + cancelled ? "" : " skipped — already terminal"); + return; + } + if (!"SKIP_TURN".equals(policyStr)) { + LOGGER.errorf("Unknown human-turn timeout policy '%s' for %s — treating as SKIP_TURN", policyStr, gcId); + } + groupConversationService.skipHumanTurnOnTimeout(gcId); + } catch (Exception e) { + LOGGER.errorf(e, "Failed to resolve timed-out human turn for group conversation %s", gcId); + } + } + private void resumeRegular(Map metadata, HitlDecision decision) { String conversationId = (String) metadata.get(HitlSchedules.METADATA_CONVERSATION_ID_KEY); try { diff --git a/src/main/java/ai/labs/eddi/engine/internal/RestGroupConversation.java b/src/main/java/ai/labs/eddi/engine/internal/RestGroupConversation.java index fb54f43b62..7d4f6e67a6 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/RestGroupConversation.java +++ b/src/main/java/ai/labs/eddi/engine/internal/RestGroupConversation.java @@ -461,6 +461,62 @@ public Response approveGroupPhase(String groupId, String gcId, GroupApprovalRequ } } + @Override + public Response submitHumanInput(String groupId, String gcId, HumanInputRequest request) { + if (request == null || request.memberId() == null || request.memberId().isBlank() + || request.content() == null || request.content().isBlank()) { + return Response.status(Response.Status.BAD_REQUEST).type(TEXT_PLAIN) + .entity("Request body must include a non-blank 'memberId' and 'content'").build(); + } + // I6: NOT the approve guard — speaking as a member is impersonation unless + // the caller IS that member (or an admin); an eddi-approver may decide + // approvals but not talk for people. + hitlAccessGuard.requireGroupHumanInputAccess(groupId, gcId, request.memberId()); + String submittedBy = identity != null && identity.getPrincipal() != null + ? identity.getPrincipal().getName() + : "anonymous"; + try { + var gc = groupConversationService.submitHumanInput(gcId, request.memberId(), request.content(), submittedBy); + return Response.ok(gc).build(); + } catch (IResourceStore.ResourceModifiedException e) { + // Double-submit or a concurrent cancel won the CAS. + return Response.status(Response.Status.CONFLICT).type(TEXT_PLAIN) + .entity("The group conversation was modified concurrently — reload and retry.").build(); + } catch (IResourceStore.ResourceNotFoundException + | ai.labs.eddi.configs.groups.IGroupConversationStore.GroupConversationGoneException e) { + LOGGER.infof("Human input for group conversation %s → not found: %s", sanitize(gcId), e.getMessage()); + return Response.status(Response.Status.NOT_FOUND).type(TEXT_PLAIN) + .entity("Group conversation not found.").build(); + } catch (IGroupConversationService.GroupDiscussionException e) { + LOGGER.infof("Human input for group conversation %s rejected (wrong state): %s", sanitize(gcId), e.getMessage()); + // Review finding: the blanket "not awaiting human input" body affirmatively + // misstated the config-drift case — the conversation IS still awaiting + // input, and every retry fails identically while the real cause (a config + // edit invalidated the bookmark) lived only in server logs. The two drift + // messages are static server-side text (no caller input), so they are safe + // to surface; everything else keeps the curated generic body. + String message = e.getMessage() != null + && (e.getMessage().startsWith("Group config changed while paused") + || e.getMessage().startsWith("The paused turn's bookmark")) + ? e.getMessage() + : "Group conversation is not awaiting human input — the turn may have been resolved, " + + "timed out, or cancelled."; + return Response.status(Response.Status.CONFLICT).type(TEXT_PLAIN) + .entity(message) + .build(); + } catch (IllegalArgumentException e) { + LOGGER.infof("Human input for group conversation %s rejected (invalid request): %s", sanitize(gcId), e.getMessage()); + return Response.status(Response.Status.BAD_REQUEST).type(TEXT_PLAIN) + .entity("Invalid submission: the memberId does not match the pending turn, or the content is " + + "blank or too long.") + .build(); + } catch (Exception e) { + LOGGER.error("Failed to submit human input for group conversation " + sanitize(gcId), e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).type(TEXT_PLAIN) + .entity("Failed to submit human input.").build(); + } + } + /** * A continuation round cannot share NEW files: attachments are granted and * injected to a member only on its first-ever turn, and on a continuation every @@ -676,16 +732,28 @@ private void sendErrorEvent(SseEventSink eventSink, Sse sse, String message) { @Override public Response getGroupApprovalStatus(String groupId, String gcId, String detail) { - validateGroupConversationOwnership(groupId, gcId, true); + // I6: the READ guard, not the strict HITL guard — the pending HUMAN + // member must be able to see the status of the turn they owe (their + // rendered prompt lives in the summary below). + hitlAccessGuard.requireGroupConversationReadAccess(groupId, gcId); try { var gc = groupConversationService.readGroupConversation(gcId); - boolean paused = gc.getState() == GroupConversation.GroupConversationState.AWAITING_APPROVAL; + boolean paused = gc.getState() == GroupConversation.GroupConversationState.AWAITING_APPROVAL + || gc.getState() == GroupConversation.GroupConversationState.AWAITING_HUMAN_INPUT; + // The approver full-view window is the APPROVAL pause only — `paused` + // also covers AWAITING_HUMAN_INPUT, where nothing awaits an approver's + // decision and the transcript would leak outside their remit (review + // finding). Summary fields keep the wider predicate. + boolean awaitingApproval = gc.getState() == GroupConversation.GroupConversationState.AWAITING_APPROVAL; if ("full".equals(detail)) { // Approver-only callers (not owner, not admin) may read the full // conversation (incl. transcript) only while it is actually awaiting - // approval — mirrors the regular surface's read-scope gate. - if (!paused && !ownershipValidator.isAdmin(identity) - && !ownershipValidator.isOwner(identity, gc.getUserId())) { + // approval — mirrors the regular surface's read-scope gate. The + // pending human member does NOT get the full view either: their + // working material is the rendered prompt in the summary. + if (!ownershipValidator.isAdmin(identity) + && !ownershipValidator.isOwner(identity, gc.getUserId()) + && !(awaitingApproval && ownershipValidator.isApprover(identity))) { return Response.status(Response.Status.FORBIDDEN) .entity("Full approval status is available to approvers only while the group " + "conversation is awaiting approval — use the summary view") @@ -711,6 +779,13 @@ public Response getGroupApprovalStatus(String groupId, String gcId, String detai summary.put("pauseReason", paused && gc.getHitlPauseReason() != null ? gc.getHitlPauseReason() : ""); summary.put("timeoutPolicy", paused && gc.getHitlTimeoutPolicy() != null ? gc.getHitlTimeoutPolicy().name() : ""); summary.put("awaitingApprovalTaskIds", awaitingTaskIds); + // I6: a human-turn pause carries WHO is up and WHAT they were asked — + // the summary is that member's working view, no transcript needed. + if (gc.getPendingHumanInput() != null) { + summary.put("pendingMemberId", gc.getPendingHumanInput().memberId()); + summary.put("pendingMemberDisplayName", gc.getPendingHumanInput().displayName()); + summary.put("pendingHumanPrompt", gc.getPendingHumanInput().renderedPrompt()); + } return Response.ok(summary).build(); } catch (IResourceStore.ResourceNotFoundException e) { return Response.status(Response.Status.NOT_FOUND).type(TEXT_PLAIN) @@ -839,6 +914,14 @@ public void onHitlPause(GroupConversationEventSink.HitlPauseEvent event) { closeQuietly(eventSink); } + @Override + public void onHumanInputRequested(GroupConversationEventSink.HumanInputRequestedEvent event) { + // I6: terminal for THIS stream, like an approval pause — the leg + // ends; a submission opens its own resumed stream if it wants one. + sendEvent(eventSink, sse, GroupConversationEventSink.EVENT_HUMAN_INPUT_REQUESTED, toJson(event)); + closeQuietly(eventSink); + } + @Override public void onHitlResume(GroupConversationEventSink.HitlResumeEvent event) { // Deliberately does NOT close — the stream continues with the @@ -875,6 +958,18 @@ public void onDecisionReached(GroupConversationEventSink.DecisionReachedEvent ev // debate verdict before a later synthesis phase). sendEvent(eventSink, sse, GroupConversationEventSink.EVENT_DECISION_REACHED, toJson(event)); } + + @Override + public void onRetroRecorded(GroupConversationEventSink.RetroRecordedEvent event) { + // Not terminal — a retro can precede further phases. + sendEvent(eventSink, sse, GroupConversationEventSink.EVENT_RETRO_RECORDED, toJson(event)); + } + + @Override + public void onArtifactUpdated(GroupConversationEventSink.ArtifactUpdatedEvent event) { + // Not terminal — artifacts are edited throughout the discussion. + sendEvent(eventSink, sse, GroupConversationEventSink.EVENT_ARTIFACT_UPDATED, toJson(event)); + } }; } diff --git a/src/main/java/ai/labs/eddi/engine/internal/groups/FacilitatorEngine.java b/src/main/java/ai/labs/eddi/engine/internal/groups/FacilitatorEngine.java new file mode 100644 index 0000000000..0ba9587c41 --- /dev/null +++ b/src/main/java/ai/labs/eddi/engine/internal/groups/FacilitatorEngine.java @@ -0,0 +1,712 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.engine.internal.groups; + +import ai.labs.eddi.configs.deployment.IDeploymentStore; +import ai.labs.eddi.configs.deployment.model.DeploymentInfo; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.ContextScope; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.DiscussionPhase; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.FacilitatorConfig; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.FacilitatorMove; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.GroupMember; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.MemberType; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.OptionsSource; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.PhaseType; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.ProtocolConfig; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.TiePolicy; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.TurnOrder; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.VoteConfig; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.VoteMethod; +import ai.labs.eddi.configs.groups.model.GroupConversation; +import ai.labs.eddi.configs.groups.model.SharedTaskList; +import ai.labs.eddi.configs.groups.model.GroupConversation.TranscriptEntry; +import ai.labs.eddi.configs.groups.model.GroupConversation.TranscriptEntryType; +import ai.labs.eddi.engine.api.IGroupConversationService.GroupDiscussionEventListener; +import ai.labs.eddi.engine.audit.AuditLedgerService; +import ai.labs.eddi.engine.audit.model.AuditEntry; +import ai.labs.eddi.engine.model.Deployment.Environment; +import ai.labs.eddi.utils.LogSanitizer; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import io.micrometer.core.instrument.MeterRegistry; +import org.jboss.logging.Logger; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * The bounded facilitator (I12): at configured checkpoints, briefs a + * facilitator agent with a compact state summary — never the full + * transcript — and executes exactly one config-enumerated move from its JSON + * reply. + *

    + * The design resolves "adaptive orchestration" against deterministic governance + * (pillar 2) by never letting the model free-form: it selects from + * {@code allowedMoves}, every selection is validated against the checkpoint's + * context, capped ({@code maxMovesPerDiscussion}, + * ≤{@value #MAX_EXTENSIONS_PER_PHASE} extensions per phase), and every executed + * move lands as a peer-hidden {@code FACILITATION} entry + audit event + + * metric. Anything unparseable, disallowed or invalid-in-context degrades to + * CONTINUE — recorded as a rejected attempt, because the audit trail must show + * the model tried. + *

    + * The facilitator call itself is a real LLM turn: it consumes a slot of the + * turn budget, its dollars land on the cost ledger (I1) like any member turn, + * and it is skipped entirely once either budget is exhausted. A facilitator + * failure never fails the discussion — the fallback is always CONTINUE. + * + * @author ginccc + */ +public class FacilitatorEngine { + + private static final Logger LOGGER = Logger.getLogger(FacilitatorEngine.class); + + /** + * The {@code memberConversationIds} key the facilitator's private conversation + * lives under. Same reasoning as {@code PhaseExecutionEngine}'s judge key: the + * facilitator may well BE the moderator agent, and its "reply with ONLY this + * JSON" exchanges must not become that agent's recent history. + */ + static final String FACILITATOR_CONVERSATION_KEY = "__facilitator"; + + /** EXTEND_PHASE ceiling per phase — an extension of an extension is a loop. */ + static final int MAX_EXTENSIONS_PER_PHASE = 2; + + /** CALL_VOTE bounds: a ballot needs a real choice, not an essay per option. */ + static final int MIN_VOTE_OPTIONS = 2; + static final int MAX_VOTE_OPTIONS = 10; + static final int MAX_VOTE_OPTION_LENGTH = 500; + + /** ESCALATE_HUMAN question bound — it renders into a pending-input prompt. */ + static final int MAX_ESCALATION_QUESTION_LENGTH = 2_000; + + /** Briefing excerpt bounds — the "never the full transcript" contract. */ + static final int BRIEFING_QUESTION_LENGTH = 300; + static final int BRIEFING_EXCERPT_LENGTH = 500; + + private static final ObjectMapper MAPPER = JsonMapper.builder() + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .build(); + + /** + * Same environment rule as {@code RecruitAgentTool}: deployed where member + * turns run. + */ + private static final Environment REQUIRED_ENV = Environment.production; + + private final MemberTurnExecutor memberTurnExecutor; + private final Supplier deploymentStore; + private final AuditLedgerService auditLedgerService; + private final MeterRegistry meterRegistry; + + public FacilitatorEngine(MemberTurnExecutor memberTurnExecutor, Supplier deploymentStore, + AuditLedgerService auditLedgerService, MeterRegistry meterRegistry) { + this.memberTurnExecutor = memberTurnExecutor; + this.deploymentStore = deploymentStore; + this.auditLedgerService = auditLedgerService; + this.meterRegistry = meterRegistry; + } + + /** + * What the discussion loop must do with an executed move. RECRUIT is applied + * inside the engine (it only mutates {@code gc}) and comes back as + * {@link Kind#NONE}; the other moves change loop control or phase structure, + * which only the loop itself may do. + */ + public record FacilitatorAction(Kind kind, DiscussionPhase insertPhase, Escalation escalation) { + + public enum Kind { + /** Continue unchanged (also every rejected/failed checkpoint). */ + NONE, + /** Break out of the current phase's remaining repeats. */ + END_PHASE, + /** The current phase gained one repeat — re-read its bound. */ + EXTEND_PHASE, + /** Insert {@code insertPhase} directly after the current phase. */ + INSERT_VOTE, + /** Commit an escalation pause and end the leg. */ + ESCALATE + } + + /** The pending-input pause ESCALATE_HUMAN asks the loop to commit. */ + public record Escalation(String principalId, String question) { + } + + static FacilitatorAction none() { + return new FacilitatorAction(Kind.NONE, null, null); + } + } + + /** + * Everything about WHERE this checkpoint sits that move validation needs. + * + * @param repeatCheckpoint + * {@code true} when the checkpoint runs inside the repeat loop + * (checkAfter=EACH_REPEAT) — the only place END_PHASE/EXTEND_PHASE + * have anything to act on + * @param moreRepeatsScheduled + * whether the current phase still has repeats after this one + * @param phaseEndedBySignal + * whether convergence/abstention already ended the phase — a + * facilitator must not overrule the deterministic exit + * @param escalationResumeTargetExists + * whether a valid resume point exists for an escalation pause + * (mid-phase: the next repeat; boundary: the next phase) + */ + public record CheckpointContext(int phaseIdx, int repeat, boolean repeatCheckpoint, boolean moreRepeatsScheduled, + boolean phaseEndedBySignal, boolean escalationResumeTargetExists) { + } + + /** + * Runs one facilitator checkpoint. Never throws: every failure path logs and + * returns {@link FacilitatorAction#none()}. + */ + public FacilitatorAction checkpoint(GroupConversation gc, AgentGroupConfiguration config, DiscussionPhase phase, + CheckpointContext ctx, ProtocolConfig protocol, String question, + GroupDiscussionEventListener listener, AtomicInteger turnCounter, int maxTurns) { + FacilitatorConfig fc = config.getFacilitator(); + if (fc == null || !fc.enabled() || fc.agentId() == null || fc.agentId().isBlank()) { + return FacilitatorAction.none(); + } + // The same two budgets every LLM call in the loop honors (the convergence + // judge set the precedent): a facilitator consult is an optimization and + // must never be the marginal call that busts either budget. + if (maxTurns > 0 && turnCounter != null && turnCounter.get() >= maxTurns) { + LOGGER.debugf("Facilitator checkpoint for %s skipped — turn budget exhausted", LogSanitizer.sanitize(gc.getId())); + return FacilitatorAction.none(); + } + if (GroupCostLedger.wouldExceedCeiling(gc, protocol)) { + LOGGER.debugf("Facilitator checkpoint for %s skipped — cost ceiling reached", LogSanitizer.sanitize(gc.getId())); + return FacilitatorAction.none(); + } + if (turnCounter != null) { + turnCounter.incrementAndGet(); + } + + String briefing = buildBriefing(gc, config, fc, phase, ctx, protocol, question); + String reply; + try { + var facilitator = new GroupMember(fc.agentId(), "Facilitator", 0, "FACILITATOR", MemberType.AGENT); + TranscriptEntry entry = memberTurnExecutor.executeAgentTurn(facilitator, gc, briefing, protocol, + ctx.phaseIdx(), phase, null, listener, null, FACILITATOR_CONVERSATION_KEY); + reply = entry != null ? entry.content() : null; + } catch (Exception e) { + // Facilitator failure → CONTINUE, per the plan. No transcript entry: + // the model never replied, so there is no attempt to audit. + LOGGER.warnf("Facilitator call failed for group %s at phase %d — continuing without intervention: %s", + LogSanitizer.sanitize(gc.getId()), ctx.phaseIdx(), LogSanitizer.sanitize(e.getMessage())); + return FacilitatorAction.none(); + } + + // A null/blank reply is a FAILED call (member-unavailable SKIP, empty + // completion), not a model attempting something — same no-entry rule as the + // exception path above. Rejection entries are reserved for replies that + // exist but violate the contract. + if (reply == null || reply.isBlank()) { + LOGGER.warnf("Facilitator returned no reply for group %s at phase %d — continuing without intervention", + LogSanitizer.sanitize(gc.getId()), ctx.phaseIdx()); + return FacilitatorAction.none(); + } + + ParsedMove parsed = parseMove(reply); + if (parsed == null) { + recordRejection(gc, fc, phase, ctx, "(unparseable)", null, + "reply did not contain the required {\"move\", \"args\", \"reason\"} JSON"); + return FacilitatorAction.none(); + } + if (parsed.move() == null) { + recordRejection(gc, fc, phase, ctx, parsed.rawMove(), parsed.reason(), + "'" + parsed.rawMove() + "' is not a recognized move"); + return FacilitatorAction.none(); + } + FacilitatorMove move = parsed.move(); + meterRegistry.counter("eddi_group_facilitator_moves_total", + "move", move.name(), "outcome", "proposed").increment(); + + if (!fc.allowedMoves().contains(move)) { + recordRejection(gc, fc, phase, ctx, move.name(), parsed.reason(), + "move is not in this group's allowedMoves"); + return FacilitatorAction.none(); + } + if (move == FacilitatorMove.CONTINUE) { + // The ambient no-op: no transcript entry, no move budget spent. The + // proposed-counter above is its record. + return FacilitatorAction.none(); + } + if (gc.getFacilitatorMoveCount() >= fc.maxMovesPerDiscussion()) { + recordRejection(gc, fc, phase, ctx, move.name(), parsed.reason(), + "move budget exhausted (" + fc.maxMovesPerDiscussion() + " non-CONTINUE moves)"); + return FacilitatorAction.none(); + } + + return switch (move) { + case END_PHASE -> executeEndPhase(gc, fc, phase, ctx, parsed); + case EXTEND_PHASE -> executeExtendPhase(gc, fc, phase, ctx, parsed); + case CALL_VOTE -> executeCallVote(gc, fc, phase, ctx, parsed); + case RECRUIT -> executeRecruit(gc, config, fc, phase, ctx, parsed); + case ESCALATE_HUMAN -> executeEscalate(gc, fc, phase, ctx, parsed); + case CONTINUE -> FacilitatorAction.none(); // unreachable — handled above + }; + } + + // ================================================================= + // Move execution + // ================================================================= + + private FacilitatorAction executeEndPhase(GroupConversation gc, FacilitatorConfig fc, DiscussionPhase phase, + CheckpointContext ctx, ParsedMove parsed) { + if (!ctx.repeatCheckpoint() || !ctx.moreRepeatsScheduled() || ctx.phaseEndedBySignal()) { + recordRejection(gc, fc, phase, ctx, parsed.move().name(), parsed.reason(), + "END_PHASE only applies mid-phase, before the phase has already ended"); + return FacilitatorAction.none(); + } + recordExecution(gc, fc, phase, ctx, parsed, "ended phase '" + phase.name() + "' early", null); + return new FacilitatorAction(FacilitatorAction.Kind.END_PHASE, null, null); + } + + private FacilitatorAction executeExtendPhase(GroupConversation gc, FacilitatorConfig fc, DiscussionPhase phase, + CheckpointContext ctx, ParsedMove parsed) { + if (!ctx.repeatCheckpoint() || ctx.phaseEndedBySignal()) { + recordRejection(gc, fc, phase, ctx, parsed.move().name(), parsed.reason(), + "EXTEND_PHASE only applies mid-phase, and never after a convergence exit"); + return FacilitatorAction.none(); + } + if (gc.facilitatorExtensionCount(ctx.phaseIdx()) >= MAX_EXTENSIONS_PER_PHASE) { + recordRejection(gc, fc, phase, ctx, parsed.move().name(), parsed.reason(), + "phase already extended " + MAX_EXTENSIONS_PER_PHASE + " times"); + return FacilitatorAction.none(); + } + gc.recordFacilitatorExtension(ctx.phaseIdx()); + recordExecution(gc, fc, phase, ctx, parsed, "extended phase '" + phase.name() + "' by one repeat", null); + return new FacilitatorAction(FacilitatorAction.Kind.EXTEND_PHASE, null, null); + } + + private FacilitatorAction executeCallVote(GroupConversation gc, FacilitatorConfig fc, DiscussionPhase phase, + CheckpointContext ctx, ParsedMove parsed) { + // Review findings: same no-overrule rule as END_PHASE/EXTEND_PHASE — a + // phase that already ended by convergence or unanimous agreement is a + // deterministic exit the facilitator must not append a vote to. And a + // discussion that already carries a DecisionRecord (AGREEMENT, VERDICT, + // an earlier vote) must keep it: the inserted vote's tally would + // REPLACE it, breaking skipIf=AGREEMENT_REACHED and un-rendering a + // debate verdict into raw judgment JSON. + if (ctx.phaseEndedBySignal()) { + recordRejection(gc, fc, phase, ctx, parsed.move().name(), parsed.reason(), + "CALL_VOTE cannot overrule a phase that already ended by convergence/agreement"); + return FacilitatorAction.none(); + } + if (gc.getDecision() != null) { + recordRejection(gc, fc, phase, ctx, parsed.move().name(), parsed.reason(), + "the discussion already carries a decision record — a facilitator vote would overwrite it"); + return FacilitatorAction.none(); + } + List options = new ArrayList<>(); + JsonNode optionsNode = parsed.args().path("options"); + if (optionsNode.isArray()) { + for (JsonNode option : optionsNode) { + String text = option.isTextual() ? option.asText().trim() : ""; + if (!text.isEmpty() && !options.contains(text)) { + options.add(text.length() > MAX_VOTE_OPTION_LENGTH ? text.substring(0, MAX_VOTE_OPTION_LENGTH) : text); + } + } + } + if (options.size() < MIN_VOTE_OPTIONS || options.size() > MAX_VOTE_OPTIONS) { + recordRejection(gc, fc, phase, ctx, parsed.move().name(), parsed.reason(), + "CALL_VOTE needs args.options with " + MIN_VOTE_OPTIONS + ".." + MAX_VOTE_OPTIONS + + " distinct non-empty strings (got " + options.size() + ")"); + return FacilitatorAction.none(); + } + // The synthetic phase is built to I14's enforced shape (PARALLEL + NONE) by + // construction — a facilitator vote gets the same structural independence a + // configured one has. One repeat, no tie policy drama: an unresolved + // facilitator vote records an honest NONE and the discussion continues. + var votePhase = new DiscussionPhase("Facilitator Vote", PhaseType.VOTE, "ALL", TurnOrder.PARALLEL, + ContextScope.NONE, false, null, 1, false, null, false, + new VoteConfig(VoteMethod.MAJORITY, OptionsSource.EXPLICIT, options, VoteConfig.DEFAULT_QUORUM, + Map.of(), false, TiePolicy.NO_DECISION)); + recordExecution(gc, fc, phase, ctx, parsed, + "called a vote on: " + String.join(" | ", options), null); + return new FacilitatorAction(FacilitatorAction.Kind.INSERT_VOTE, votePhase, null); + } + + /** + * The I7 path, mirrored from {@code RecruitAgentTool} (which is a per-turn tool + * over the live registry and cannot be constructed here): same already-member + * check, same {@code maxRecruitedAgentsPerDiscussion} cap, same + * deployed-and-ready requirement, same synchronized commit over both lists. + */ + private FacilitatorAction executeRecruit(GroupConversation gc, AgentGroupConfiguration config, FacilitatorConfig fc, + DiscussionPhase phase, CheckpointContext ctx, ParsedMove parsed) { + String agentId = parsed.args().path("agentId").asText("").trim(); + if (agentId.isEmpty()) { + recordRejection(gc, fc, phase, ctx, parsed.move().name(), parsed.reason(), + "RECRUIT needs args.agentId"); + return FacilitatorAction.none(); + } + String problem = recruitProblem(gc, config, agentId); + if (problem != null) { + recordRejection(gc, fc, phase, ctx, parsed.move().name(), parsed.reason(), problem); + return FacilitatorAction.none(); + } + var recruit = new GroupMember(agentId, agentId, Integer.MAX_VALUE, null, MemberType.AGENT); + synchronized (gc.getRecruitedAgentIds()) { + if (recruitProblem(gc, config, agentId) != null) { + recordRejection(gc, fc, phase, ctx, parsed.move().name(), parsed.reason(), + "agent was recruited concurrently or the limit was reached"); + return FacilitatorAction.none(); + } + gc.addDynamicMember(recruit); + gc.getRecruitedAgentIds().add(agentId); + } + gc.addMemberDisplayName(agentId, agentId); + recordExecution(gc, fc, phase, ctx, parsed, "recruited agent '" + agentId + "'", agentId); + return FacilitatorAction.none(); // applied here — nothing for the loop to do + } + + private FacilitatorAction executeEscalate(GroupConversation gc, FacilitatorConfig fc, DiscussionPhase phase, + CheckpointContext ctx, ParsedMove parsed) { + if (fc.escalateTo() == null || fc.escalateTo().isBlank()) { + recordRejection(gc, fc, phase, ctx, parsed.move().name(), parsed.reason(), + "no escalateTo principal configured"); + return FacilitatorAction.none(); + } + if (!ctx.escalationResumeTargetExists()) { + recordRejection(gc, fc, phase, ctx, parsed.move().name(), parsed.reason(), + "nothing left to resume into after this checkpoint — escalation would strand the discussion"); + return FacilitatorAction.none(); + } + String escalationQuestion = parsed.args().path("question").asText("").trim(); + if (escalationQuestion.isEmpty()) { + escalationQuestion = parsed.reason() != null ? parsed.reason().trim() : ""; + } + if (escalationQuestion.isEmpty()) { + recordRejection(gc, fc, phase, ctx, parsed.move().name(), parsed.reason(), + "ESCALATE_HUMAN needs args.question (or a reason to use as one)"); + return FacilitatorAction.none(); + } + if (escalationQuestion.length() > MAX_ESCALATION_QUESTION_LENGTH) { + escalationQuestion = escalationQuestion.substring(0, MAX_ESCALATION_QUESTION_LENGTH); + } + recordExecution(gc, fc, phase, ctx, parsed, + "escalated to '" + fc.escalateTo() + "': " + escalationQuestion, null); + return new FacilitatorAction(FacilitatorAction.Kind.ESCALATE, null, + new FacilitatorAction.Escalation(fc.escalateTo(), escalationQuestion)); + } + + /** + * The recruit validation matrix, as one human-readable problem or {@code null}. + */ + private String recruitProblem(GroupConversation gc, AgentGroupConfiguration config, String agentId) { + if (config.getMembers() != null + && config.getMembers().stream().anyMatch(m -> agentId.equals(m.agentId()))) { + return "agent '" + agentId + "' is already a configured member"; + } + if (gc.getRecruitedAgentIds().contains(agentId)) { + return "agent '" + agentId + "' was already recruited"; + } + List dynamic = gc.getDynamicMembers(); + synchronized (dynamic) { + if (dynamic.stream().anyMatch(m -> agentId.equals(m.agentId()))) { + return "agent '" + agentId + "' is already a dynamic member"; + } + } + if (gc.getMemberConversationIds() != null && gc.getMemberConversationIds().containsKey(agentId)) { + return "agent '" + agentId + "' is already participating"; + } + int cap = config.getDynamicAgents() != null + ? config.getDynamicAgents().getMaxRecruitedAgentsPerDiscussion() + : new AgentGroupConfiguration.DynamicAgentConfig().getMaxRecruitedAgentsPerDiscussion(); + if (gc.getRecruitedAgentIds().size() >= cap) { + return "recruit limit of " + cap + " reached"; + } + if (!isDeployedAndReady(agentId)) { + return "agent '" + agentId + "' is not deployed and ready"; + } + return null; + } + + /** Same rule as {@code RecruitAgentTool}: unverifiable means not ready. */ + private boolean isDeployedAndReady(String agentId) { + IDeploymentStore store = deploymentStore != null ? deploymentStore.get() : null; + if (store == null) { + return false; + } + try { + List deployments = store.readDeploymentInfos(DeploymentInfo.DeploymentStatus.deployed); + return deployments != null && deployments.stream() + .anyMatch(d -> d != null && agentId.equals(d.getAgentId()) + && (d.getEnvironment() == null || d.getEnvironment() == REQUIRED_ENV)); + } catch (Exception e) { + LOGGER.warnf("Could not verify deployment of '%s' — refusing facilitator recruitment: %s", + LogSanitizer.sanitize(agentId), LogSanitizer.sanitize(e.getMessage())); + return false; + } + } + + // ================================================================= + // Recording — transcript, audit, metrics + // ================================================================= + + private void recordExecution(GroupConversation gc, FacilitatorConfig fc, DiscussionPhase phase, + CheckpointContext ctx, ParsedMove parsed, String summary, String targetAgentId) { + gc.setFacilitatorMoveCount(gc.getFacilitatorMoveCount() + 1); + String content = "Facilitator " + summary + + (parsed.reason() != null && !parsed.reason().isBlank() ? " — " + parsed.reason().trim() : ""); + gc.getTranscript().add(new TranscriptEntry(fc.agentId(), "Facilitator", content, ctx.phaseIdx(), phase.name(), + TranscriptEntryType.FACILITATION, Instant.now(), null, targetAgentId)); + meterRegistry.counter("eddi_group_facilitator_moves_total", + "move", parsed.move().name(), "outcome", "executed").increment(); + auditMove(gc, parsed, ctx, "EXECUTED", null); + LOGGER.infof("Facilitator move %s executed for group %s at phase %d (%d of %d moves used)", + parsed.move().name(), LogSanitizer.sanitize(gc.getId()), ctx.phaseIdx(), + gc.getFacilitatorMoveCount(), fc.maxMovesPerDiscussion()); + } + + /** + * A rejected attempt: CONTINUE + WARN + a FACILITATION entry that shows the + * model tried. Never consumes the move budget. + */ + private void recordRejection(GroupConversation gc, FacilitatorConfig fc, DiscussionPhase phase, + CheckpointContext ctx, String attemptedMove, String reason, String why) { + String content = "Facilitator attempted " + attemptedMove + " — rejected: " + why + + (reason != null && !reason.isBlank() ? ". Facilitator's reason: " + reason.trim() : ""); + gc.getTranscript().add(new TranscriptEntry(fc.agentId(), "Facilitator", content, ctx.phaseIdx(), phase.name(), + TranscriptEntryType.FACILITATION, Instant.now(), null, null)); + // Review finding: the raw LLM-authored move string as a metric tag value + // is unbounded label cardinality — every hallucinated name became a new + // Prometheus time series for the JVM's lifetime. The tag is bounded to + // the enum names + UNKNOWN; the raw string stays in the transcript/log. + String moveTag; + try { + moveTag = FacilitatorMove.valueOf(attemptedMove).name(); + } catch (IllegalArgumentException | NullPointerException e) { + moveTag = "UNKNOWN"; + } + meterRegistry.counter("eddi_group_facilitator_moves_total", + "move", moveTag, "outcome", "rejected").increment(); + LOGGER.warnf("Facilitator move %s rejected for group %s at phase %d: %s", + LogSanitizer.sanitize(attemptedMove), LogSanitizer.sanitize(gc.getId()), ctx.phaseIdx(), + LogSanitizer.sanitize(why)); + } + + private void auditMove(GroupConversation gc, ParsedMove parsed, CheckpointContext ctx, String outcome, String why) { + if (auditLedgerService == null || !auditLedgerService.isEnabled()) { + return; + } + try { + var detail = new LinkedHashMap(); + detail.put("move", parsed.move() != null ? parsed.move().name() : parsed.rawMove()); + detail.put("outcome", outcome); + detail.put("phaseIdx", ctx.phaseIdx()); + detail.put("repeat", ctx.repeat()); + if (parsed.reason() != null) { + detail.put("reason", parsed.reason()); + } + if (why != null) { + detail.put("rejectionReason", why); + } + auditLedgerService.submit(new AuditEntry( + UUID.randomUUID().toString(), gc.getId(), gc.getGroupId(), null, gc.getUserId(), + null, -1, "group.facilitator", "group", -1, 0L, + Map.of(), detail, null, null, List.of(), 0.0, + Instant.now(), null, null)); + } catch (Exception e) { + LOGGER.warnf("Failed to submit facilitator audit entry for group conversation %s: %s", + LogSanitizer.sanitize(gc.getId()), e.getMessage()); + } + } + + // ================================================================= + // Reply parsing — the shared three-tier discipline + // ================================================================= + + /** + * @param move + * {@code null} when {@code rawMove} named no known move + */ + record ParsedMove(FacilitatorMove move, String rawMove, JsonNode args, String reason) { + } + + /** + * Strict JSON → embedded JSON → give up ({@code null}). Mirrors + * {@link VoteTallyEngine}/{@code DebateVerdictParser}: {@code + * FAIL_ON_TRAILING_TOKENS} keeps a two-object reply ambiguous, and an + * unreadable reply has no state effect (it becomes a recorded rejection). + */ + static ParsedMove parseMove(String reply) { + if (reply == null || reply.isBlank()) { + return null; + } + JsonNode node = readJson(reply); + if (node == null) { + node = readJson(embeddedJson(reply)); + } + if (node == null || !node.isObject() || !node.path("move").isTextual()) { + return null; + } + String rawMove = node.path("move").asText().trim(); + // Bound the LLM-authored string before it rides into transcript entries + // and logs — the model owns its content, not its size. + if (rawMove.length() > 60) { + rawMove = rawMove.substring(0, 60) + "…"; + } + FacilitatorMove move = null; + try { + move = FacilitatorMove.valueOf(rawMove.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + // unknown move — reported by the caller as a rejected attempt + } + JsonNode args = node.path("args"); + if (!args.isObject()) { + args = MAPPER.createObjectNode(); + } + String reason = node.path("reason").isTextual() ? node.path("reason").asText() : null; + return new ParsedMove(move, rawMove, args, reason); + } + + private static JsonNode readJson(String text) { + if (text == null || text.isBlank()) { + return null; + } + try { + return MAPPER.readTree(text.trim()); + } catch (Exception e) { + return null; + } + } + + private static String embeddedJson(String content) { + int start = content.indexOf('{'); + int end = content.lastIndexOf('}'); + return start >= 0 && end > start ? content.substring(start, end + 1) : null; + } + + // ================================================================= + // Briefing — compact by construction + // ================================================================= + + /** + * The facilitator's entire view of the discussion: derived counts, budget + * arithmetic and two capped excerpts. Deliberately NOT the transcript — a + * facilitator that reads everything becomes an orchestrator, and its context + * cost would grow with the discussion instead of staying flat. + */ + String buildBriefing(GroupConversation gc, AgentGroupConfiguration config, FacilitatorConfig fc, + DiscussionPhase phase, CheckpointContext ctx, ProtocolConfig protocol, String question) { + var sb = new StringBuilder(1_024); + sb.append("You are the neutral facilitator of a multi-agent group discussion. ") + .append("Review the state summary and choose exactly ONE move.\n") + .append("Respond with ONLY this JSON: {\"move\": \"\", \"args\": {…}, \"reason\": \"\"}\n\n") + .append("Moves you may choose:\n"); + for (FacilitatorMove move : fc.allowedMoves()) { + sb.append("- ").append(moveContract(move)).append('\n'); + } + + sb.append("\nState:\n"); + if (question != null && !question.isBlank()) { + sb.append("- Question: ").append(truncate(question, BRIEFING_QUESTION_LENGTH)).append('\n'); + } + int repeats = Math.max(phase.repeats(), 1); + sb.append("- Current phase: '").append(phase.name()).append("' (").append(phase.type()) + .append("), repeat ").append(ctx.repeat() + 1).append(" of ").append(repeats).append('\n'); + Double ceiling = protocol != null ? protocol.maxCostPerDiscussion() : null; + sb.append("- Cost so far: $").append(String.format(Locale.ROOT, "%.4f", gc.getTotalCost())) + .append(ceiling != null ? " of $" + String.format(Locale.ROOT, "%.2f", ceiling) + " ceiling" : " (no ceiling)") + .append('\n'); + sb.append("- Non-CONTINUE moves you have left: ") + .append(Math.max(0, fc.maxMovesPerDiscussion() - gc.getFacilitatorMoveCount())).append('\n'); + + if (config.getMembers() != null && !config.getMembers().isEmpty()) { + sb.append("- Roster: ").append(config.getMembers().stream() + .map(m -> m.agentId() + (m.role() != null ? " (" + m.role() + ")" : "")) + .collect(Collectors.joining(", "))).append('\n'); + } + List dynamic = gc.getDynamicMembers(); + synchronized (dynamic) { + if (!dynamic.isEmpty()) { + sb.append("- Recruited: ").append(dynamic.stream().map(GroupMember::agentId) + .collect(Collectors.joining(", "))).append('\n'); + } + } + + Map phaseCounts; + String latestConvergence = null; + String latestExcerpt = null; + synchronized (gc.getTranscript()) { + phaseCounts = gc.getTranscript().stream() + .filter(e -> e.phaseIndex() == ctx.phaseIdx()) + .collect(Collectors.groupingBy(TranscriptEntry::type, LinkedHashMap::new, Collectors.counting())); + for (int i = gc.getTranscript().size() - 1; i >= 0; i--) { + TranscriptEntry entry = gc.getTranscript().get(i); + if (latestConvergence == null && entry.type() == TranscriptEntryType.CONVERGENCE + && entry.content() != null) { + latestConvergence = truncate(entry.content(), 200); + } + if (latestExcerpt == null && entry.content() != null + && (entry.type() == TranscriptEntryType.SYNTHESIS + || entry.type() == TranscriptEntryType.OPINION + || entry.type() == TranscriptEntryType.REVISION)) { + latestExcerpt = truncate(entry.content(), BRIEFING_EXCERPT_LENGTH); + } + if (latestConvergence != null && latestExcerpt != null) { + break; + } + } + } + if (!phaseCounts.isEmpty()) { + sb.append("- Entries this phase: ").append(phaseCounts.entrySet().stream() + .map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining(", "))).append('\n'); + } + if (latestConvergence != null) { + sb.append("- Latest convergence check: ").append(latestConvergence).append('\n'); + } + var taskList = gc.getTaskList(); + // One snapshot: getTasks() is synchronized per call, but an emptiness + // check followed by a second read is a check-then-act across two + // monitors' worth of state — stream the single copy instead. + var taskSnapshot = taskList != null ? taskList.getTasks() : List.of(); + if (!taskSnapshot.isEmpty()) { + var byStatus = taskSnapshot.stream() + .collect(Collectors.groupingBy(t -> t.status().name(), LinkedHashMap::new, Collectors.counting())); + sb.append("- Tasks: ").append(byStatus.entrySet().stream() + .map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining(", "))).append('\n'); + } + if (latestExcerpt != null) { + sb.append("- Latest contribution excerpt: ").append(latestExcerpt).append('\n'); + } + sb.append("\nWhen in doubt, choose CONTINUE."); + return sb.toString(); + } + + private static String moveContract(FacilitatorMove move) { + return switch (move) { + case CONTINUE -> "CONTINUE — no intervention (no args)"; + case END_PHASE -> "END_PHASE — skip this phase's remaining repeats (no args)"; + case EXTEND_PHASE -> "EXTEND_PHASE — give this phase one more repeat (no args)"; + case CALL_VOTE -> "CALL_VOTE — insert a vote next; args: {\"options\": [\"…\", \"…\"]} with " + + MIN_VOTE_OPTIONS + "-" + MAX_VOTE_OPTIONS + " short, distinct options"; + case RECRUIT -> "RECRUIT — bring a deployed agent into the roster; args: {\"agentId\": \"…\"}"; + case ESCALATE_HUMAN -> "ESCALATE_HUMAN — pause and ask the human overseer; args: {\"question\": \"…\"}"; + }; + } + + private static String truncate(String text, int max) { + String trimmed = text.strip(); + return trimmed.length() <= max ? trimmed : trimmed.substring(0, max) + "…"; + } +} diff --git a/src/main/java/ai/labs/eddi/engine/internal/groups/GroupContextBuilder.java b/src/main/java/ai/labs/eddi/engine/internal/groups/GroupContextBuilder.java index a418acdc4f..df0f32da45 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/groups/GroupContextBuilder.java +++ b/src/main/java/ai/labs/eddi/engine/internal/groups/GroupContextBuilder.java @@ -4,18 +4,26 @@ */ package ai.labs.eddi.engine.internal.groups; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.ContextScope; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.ContextWindowConfig; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.DiscussionPhase; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.GroupMember; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.PhaseType; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.VoteConfig; import ai.labs.eddi.configs.groups.model.DiscussionStylePresets; +import ai.labs.eddi.configs.groups.model.GroupConversation; import ai.labs.eddi.configs.groups.model.GroupConversation.TranscriptEntry; import ai.labs.eddi.configs.groups.model.GroupConversation.TranscriptEntryType; import ai.labs.eddi.engine.memory.ConversationOutputExtractor; import ai.labs.eddi.engine.memory.model.SimpleConversationMemorySnapshot; +import ai.labs.eddi.modules.llm.impl.SummarizationService; +import ai.labs.eddi.modules.llm.impl.TokenPricing; import ai.labs.eddi.modules.templating.ITemplatingEngine; +import ai.labs.eddi.utils.LogSanitizer; import org.jboss.logging.Logger; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -51,6 +59,11 @@ public String buildPhaseInput(DiscussionPhase phase, GroupMember speaker, String return buildPhaseInput(phase, speaker, question, transcript, phaseIdx, target, null); } + public String buildPhaseInput(DiscussionPhase phase, GroupMember speaker, String question, List transcript, int phaseIdx, + GroupMember target, List allMembers) { + return buildPhaseInput(phase, speaker, question, transcript, phaseIdx, target, allMembers, null, null); + } + /** * @param allMembers * the full group roster, used only by {@code ARGUE}/{@code @@ -60,9 +73,30 @@ public String buildPhaseInput(DiscussionPhase phase, GroupMember speaker, String * the not-me filter, which is only correct for a 2-member debate — * existing callers that never reach those two phase types are * unaffected either way. + * @param window + * I9 transcript windowing, applied to the FULL/ANONYMOUS + * scope-filtered context only. {@code null} disables windowing + * (every pre-I9 caller). + * @param gc + * the discussion carrying the rolling window summaries; only read + * when {@code window} is enabled */ public String buildPhaseInput(DiscussionPhase phase, GroupMember speaker, String question, List transcript, int phaseIdx, - GroupMember target, List allMembers) { + GroupMember target, List allMembers, ContextWindowConfig window, GroupConversation gc) { + return buildPhaseInput(phase, speaker, question, transcript, phaseIdx, target, allMembers, window, gc, null); + } + + /** + * @param retroConfig + * the group's RETRO settings; only the {@code RETRO} case reads it + * (the template quotes {@code maxLessonsPerRun} to the model). + * {@code null} falls back to the default cap — review finding: the + * template always quoted the DEFAULT, so a group configured above it + * could never obtain its configured number of lessons. + */ + public String buildPhaseInput(DiscussionPhase phase, GroupMember speaker, String question, List transcript, int phaseIdx, + GroupMember target, List allMembers, ContextWindowConfig window, GroupConversation gc, + AgentGroupConfiguration.RetroConfig retroConfig) { String template = phase.inputTemplate() != null ? phase.inputTemplate() @@ -79,7 +113,7 @@ public String buildPhaseInput(DiscussionPhase phase, GroupMember speaker, String // Phase-type specific variables switch (phase.type()) { case OPINION -> { - List> prev = filterByScope(transcript, phase.contextScope(), phaseIdx, speaker); + List> prev = filterByScope(transcript, phase.contextScope(), phaseIdx, speaker, window, gc); data.put("previousResponses", prev); } case CRITIQUE -> { @@ -185,6 +219,44 @@ public String buildPhaseInput(DiscussionPhase phase, GroupMember speaker, String case VERIFY -> { // Completed tasks populated by executeTaskPhase } + case VOTE -> { + // I14: the ballot prompt renders the options and the JSON contract. + // Scope is NONE (save-time enforced), so no transcript context rides + // along — blindness is structural, not this template's doing. + var voteConfig = phase.voteConfig() != null ? phase.voteConfig() : new VoteConfig(); + data.put("options", VoteTallyEngine.resolveOptions(voteConfig, transcript)); + data.put("ballotContract", VoteTallyEngine.ballotContract(voteConfig.method())); + } + case PROPOSAL, BARGAIN -> { + // I11: same peer context an OPINION turn gets — the negotiation + // TABLE (open proposals + concession ledger) is appended after + // rendering by NegotiationEngine.appendStateIfRelevant, because it + // lives on the GroupConversation, which this method does not see. + List> prev = filterByScope(transcript, phase.contextScope(), phaseIdx, speaker); + data.put("previousResponses", prev); + } + case RETRO -> { + // I8: the retro reviews the whole discussion — same full-picture + // filter as SYNTHESIS (a retrospective on a windowed excerpt would + // draw lessons from an excerpt). + List> fullTranscript = transcript.stream() + .filter(e -> e.content() != null && e.type() != TranscriptEntryType.ERROR && e.type() != TranscriptEntryType.SKIPPED + && e.type() != TranscriptEntryType.QUESTION) + .map(e -> { + Map t = new LinkedHashMap<>(); + t.put("speaker", e.speakerDisplayName()); + t.put("content", e.content()); + t.put("phaseName", e.phaseName() != null ? e.phaseName() : ""); + return t; + }).collect(Collectors.toList()); + data.put("transcript", fullTranscript); + // The template QUOTES the configured cap so the model is asked for + // exactly what the group wants; RetroEngine still ENFORCES it at + // parse time regardless of what the model returns. + data.put("maxLessonsPerRun", retroConfig != null + ? retroConfig.maxLessonsPerRun() + : AgentGroupConfiguration.RetroConfig.DEFAULT_MAX_PER_RUN); + } default -> { // All PhaseType values handled above; default required by checkstyle } @@ -299,29 +371,275 @@ private static Set debatingRoles(List allMembers) { } public List> filterByScope(List transcript, ContextScope scope, int currentPhaseIdx, GroupMember speaker) { + return filterByScope(transcript, scope, currentPhaseIdx, speaker, null, null); + } + + /** + * As {@link #filterByScope(List, ContextScope, int, GroupMember)}, windowed + * (I9): when {@code window} is enabled, the scope is {@code FULL} or + * {@code ANONYMOUS}, and the filtered context exceeds {@code maxRecentEntries}, + * the older entries collapse into a single leading pseudo-entry — the rolling + * summary {@code gc} carries when one covers the omitted range, otherwise a + * plain "[n earlier entries omitted]" marker (the truncation fallback for + * {@code summarizeOverflow=false} and for summarizer failure). The recent + * entries render verbatim; the stored transcript is never modified. + */ + public List> filterByScope(List transcript, ContextScope scope, int currentPhaseIdx, GroupMember speaker, + ContextWindowConfig window, GroupConversation gc) { if (scope == null || scope == ContextScope.NONE) { return List.of(); } - return transcript.stream().filter(e -> e.content() != null && e.type() != TranscriptEntryType.ERROR && e.type() != TranscriptEntryType.SKIPPED - && e.type() != TranscriptEntryType.QUESTION).filter(e -> isVisibleToPeers(e, currentPhaseIdx)).filter(e -> switch (scope) { - case FULL -> true; - case LAST_PHASE -> e.phaseIndex() >= currentPhaseIdx - 1; - case ANONYMOUS -> true; // Content included, attribution stripped - case OWN_FEEDBACK -> speaker.agentId().equals(e.targetAgentId()); - case NONE -> false; - case TASK_ONLY, TASK_WITH_DEPS -> false; // Handled by task-specific logic - }).map(e -> { - Map entry = new LinkedHashMap<>(); - if (scope == ContextScope.ANONYMOUS) { - entry.put("speaker", "Anonymous"); - } else { - entry.put("speaker", e.speakerDisplayName()); - } - entry.put("content", e.content()); - entry.put("phaseName", e.phaseName() != null ? e.phaseName() : ""); - return entry; - }).collect(Collectors.toList()); + // Sequential phases hand in the LIVE synchronized transcript, which tool + // threads (e.g. a recruit's FACILITATION entry) can append to mid-render. + // Copy under the wrapper's own monitor — Collections.synchronizedList's + // add() locks the wrapper object, which is exactly what this locks — so + // the indexed walk below runs over one consistent view. Copying a + // snapshot the parallel path already made is a cheap re-wrap. + List entries; + synchronized (transcript) { + entries = List.copyOf(transcript); + } + + // Raw transcript indices ride along because the rolling summary's + // summaryUpToIndex is a raw-transcript boundary, not a filtered-list one. + List rawIndices = new ArrayList<>(); + List filtered = new ArrayList<>(); + for (int i = 0; i < entries.size(); i++) { + TranscriptEntry e = entries.get(i); + boolean included = e.content() != null && e.type() != TranscriptEntryType.ERROR && e.type() != TranscriptEntryType.SKIPPED + && e.type() != TranscriptEntryType.QUESTION && isVisibleToPeers(e, currentPhaseIdx) && switch (scope) { + case FULL -> true; + case LAST_PHASE -> e.phaseIndex() >= currentPhaseIdx - 1; + case ANONYMOUS -> true; // Content included, attribution stripped + case OWN_FEEDBACK -> speaker.agentId().equals(e.targetAgentId()); + case NONE -> false; + case TASK_ONLY, TASK_WITH_DEPS -> false; // Handled by task-specific logic + }; + if (included) { + rawIndices.add(i); + filtered.add(e); + } + } + + boolean windowed = window != null && window.enabled() && gc != null + && (scope == ContextScope.FULL || scope == ContextScope.ANONYMOUS) + && filtered.size() > window.maxRecentEntries(); + if (!windowed) { + return renderEntries(filtered, scope); + } + + String summary = scope == ContextScope.ANONYMOUS ? gc.getAnonymousTranscriptSummary() : gc.getTranscriptSummary(); + int summaryUpTo = scope == ContextScope.ANONYMOUS ? gc.getAnonymousSummaryUpToIndex() : gc.getSummaryUpToIndex(); + + if (summary != null && !summary.isBlank() && summaryUpTo > 0) { + // Verbatim tail = everything the summary does not cover. Split on the raw + // boundary rather than "last maxRecentEntries" so there is never a gap + // (entries neither summarized nor shown) and never duplication (entries + // both summarized and shown). Between phase boundaries the tail can grow + // a few entries past the cap; the next boundary's summary extension + // re-tightens it. + List verbatim = new ArrayList<>(); + int omitted = 0; + for (int i = 0; i < filtered.size(); i++) { + if (rawIndices.get(i) >= summaryUpTo) { + verbatim.add(filtered.get(i)); + } else { + omitted++; + } + } + if (omitted == 0) { + return renderEntries(filtered, scope); + } + List> result = new ArrayList<>(); + result.add(syntheticEntry("Summary of the earlier discussion:\n" + summary)); + result.addAll(renderEntries(verbatim, scope)); + return result; + } + + // Truncation fallback — summarizeOverflow=false, no summarizer configured, + // or every summarization attempt so far failed. + int omitted = filtered.size() - window.maxRecentEntries(); + List> result = new ArrayList<>(); + result.add(syntheticEntry("[%d earlier entries omitted]".formatted(omitted))); + result.addAll(renderEntries(filtered.subList(omitted, filtered.size()), scope)); + return result; + } + + private static List> renderEntries(List entries, ContextScope scope) { + return entries.stream().map(e -> { + Map entry = new LinkedHashMap<>(); + if (scope == ContextScope.ANONYMOUS) { + entry.put("speaker", "Anonymous"); + } else { + entry.put("speaker", e.speakerDisplayName()); + } + entry.put("content", e.content()); + entry.put("phaseName", e.phaseName() != null ? e.phaseName() : ""); + return entry; + }).collect(Collectors.toList()); + } + + /** + * The windowing pseudo-entry. "System" as the speaker, never a member name — + * under ANONYMOUS scope it must not read as an attribution. + */ + private static Map syntheticEntry(String content) { + Map entry = new LinkedHashMap<>(); + entry.put("speaker", "System"); + entry.put("content", content); + entry.put("phaseName", "(earlier discussion)"); + return entry; + } + + /** + * Summarization instructions for the I9 window summary. Tag-style constraint + * header rather than instruction prose, per the plan's research-adopted + * template note. + */ + static final String WINDOW_SUMMARY_INSTRUCTIONS = """ + Summarize the group discussion entries below for a participant who must continue the discussion. + Preserve: each speaker's position and key arguments, decisions reached, open disagreements, and task outcomes. + [format: bullet points] [limit: 500 tokens]"""; + + /** + * Extends the discussion's rolling window summary (I9), called from the + * discussion loop at phase boundaries only — never per member turn, which is + * exactly the quadratic re-feeding this feature exists to stop. + *

    + * A no-op unless the upcoming phase is an OPINION phase with FULL or ANONYMOUS + * scope (the only context {@link #filterByScope} windows), the window is + * enabled with {@code summarizeOverflow=true}, and the transcript has outgrown + * {@code maxRecentEntries} beyond what the summary already covers. The + * extension is incremental: the summarizer is handed the previous summary plus + * only the new slice, mirroring {@code ConversationSummarizer}'s algorithm — + * and like it, a failed call leaves the stored state untouched (WARN, never + * blocks the discussion), so rendering falls back to the truncation marker and + * the next boundary catches up with a larger batch. + *

    + * The ANONYMOUS variant is summarized from "Anonymous"-labelled input — the + * same labels the scope filter itself produces — so the stored anonymous + * summary can never de-anonymize a peer. Summarizer spend is attributed to the + * discussion's cost ledger (I1) when the window config carries prices. + */ + public void updateWindowSummary(GroupConversation gc, DiscussionPhase phase, ContextWindowConfig window, + SummarizationService summarizationService) { + if (window == null || !window.enabled() || !Boolean.TRUE.equals(window.summarizeOverflow()) + || gc == null || phase == null || phase.type() != PhaseType.OPINION) { + return; + } + ContextScope scope = phase.contextScope(); + if (scope != ContextScope.FULL && scope != ContextScope.ANONYMOUS) { + return; + } + boolean anonymous = scope == ContextScope.ANONYMOUS; + + List transcript; + synchronized (gc.getTranscript()) { + transcript = List.copyOf(gc.getTranscript()); + } + // The boundary counts VISIBLE (summarizable) entries back from the tail, + // not raw entries: bookkeeping rows (SKIPPED, CONVERGENCE, …) in the tail + // would otherwise shrink the verbatim window below maxRecentEntries while + // newer real contributions get summarized away. + int coverThrough = summaryBoundary(transcript, window.maxRecentEntries()); + int alreadyCovered = anonymous ? gc.getAnonymousSummaryUpToIndex() : gc.getSummaryUpToIndex(); + if (coverThrough <= alreadyCovered) { + return; + } + if (summarizationService == null || window.llmProvider() == null || window.llmModel() == null) { + LOGGER.warnf("Group %s: contextWindow.summarizeOverflow is on but no summarizer %s — falling back to plain truncation", + LogSanitizer.sanitize(gc.getId()), summarizationService == null ? "service is available" : "llmProvider/llmModel is configured"); + return; + } + + String newSlice = renderForSummarizer(transcript.subList(alreadyCovered, coverThrough), anonymous); + if (newSlice.isBlank()) { + return; + } + String previousSummary = anonymous ? gc.getAnonymousTranscriptSummary() : gc.getTranscriptSummary(); + String content = previousSummary != null && !previousSummary.isBlank() + ? "## Summary of entries 1-" + alreadyCovered + ":\n" + previousSummary + "\n\n## New entries:\n" + newSlice + : newSlice; + + try { + var result = summarizationService.summarizeWithUsage(content, WINDOW_SUMMARY_INSTRUCTIONS, + window.llmProvider(), window.llmModel()); + if (result.summary().isBlank()) { + LOGGER.warnf("Group %s: window summarization returned empty — keeping previous state, will retry next boundary", + LogSanitizer.sanitize(gc.getId())); + return; + } + if (anonymous) { + gc.setAnonymousTranscriptSummary(result.summary()); + gc.setAnonymousSummaryUpToIndex(coverThrough); + } else { + gc.setTranscriptSummary(result.summary()); + gc.setSummaryUpToIndex(coverThrough); + } + // I1: the summarizer's own spend is discussion spend. Keyed per variant + // and boundary so a re-run of the same extension replaces (idempotent) + // while distinct extensions sum. + double cost = TokenPricing.cost(window.inputPricePer1M(), window.outputPricePer1M(), + Map.of("inputTokens", result.inputTokens(), "outputTokens", result.outputTokens())); + GroupCostLedger.recordSystemCost(gc, "system:summarizer:" + (anonymous ? "anon" : "full") + ":" + coverThrough, cost); + } catch (Exception e) { + LOGGER.warnf("Group %s: window summarization failed (%s) — falling back to plain truncation for this window", + LogSanitizer.sanitize(gc.getId()), e.getMessage()); + } + } + + /** + * The raw index the summary should cover through (exclusive): the position of + * the {@code maxRecentEntries}-th summarizable entry counted from the + * tail. {@code 0} when the transcript holds fewer than the cap — nothing to + * summarize yet. + */ + static int summaryBoundary(List transcript, int maxRecentEntries) { + int visibleSeen = 0; + for (int i = transcript.size() - 1; i >= 0; i--) { + if (isSummarizable(transcript.get(i))) { + visibleSeen++; + if (visibleSeen == maxRecentEntries) { + return i; + } + } + } + return 0; + } + + /** + * The shared content predicate for windowing: what counts toward the verbatim + * tail and what may enter a summary. The same exclusions the peer-visibility + * matrix applies to rendered context — bookkeeping a member never sees must not + * leak into a summary a member reads. (VOTE/BID need no phase check here: + * summaries are extended at phase boundaries, where every ballot on the + * transcript belongs to a completed phase.) + */ + private static boolean isSummarizable(TranscriptEntry e) { + return e != null && e.content() != null && e.type() != TranscriptEntryType.ERROR && e.type() != TranscriptEntryType.SKIPPED + && e.type() != TranscriptEntryType.QUESTION && e.type() != TranscriptEntryType.ABSTAINED + && e.type() != TranscriptEntryType.CONVERGENCE && e.type() != TranscriptEntryType.FACILITATION; + } + + /** + * Renders transcript entries as summarizer input, {@link #isSummarizable} + * entries only. + */ + private static String renderForSummarizer(List entries, boolean anonymous) { + var sb = new StringBuilder(); + for (TranscriptEntry e : entries) { + if (!isSummarizable(e)) { + continue; + } + String label = anonymous ? "Anonymous" : e.speakerDisplayName(); + sb.append("- ").append(label); + if (e.phaseName() != null) { + sb.append(" (").append(e.phaseName()).append(')'); + } + sb.append(": ").append(e.content()).append('\n'); + } + return sb.toString(); } /** @@ -370,6 +688,13 @@ public TranscriptEntryType mapPhaseToEntryType(PhaseType type) { case PLAN -> TranscriptEntryType.PLAN; case EXECUTE -> TranscriptEntryType.TASK_RESULT; case VERIFY -> TranscriptEntryType.VERIFICATION; + // I14: ballots are VOTE entries — peer-hidden while their phase runs + // (F4's commit-reveal), public once it completes. + case VOTE -> TranscriptEntryType.VOTE; + case PROPOSAL -> TranscriptEntryType.PROPOSAL; + case BARGAIN -> TranscriptEntryType.BARGAIN; + // I8: retro contributions are public RETRO entries (F4 default). + case RETRO -> TranscriptEntryType.RETRO; }; } diff --git a/src/main/java/ai/labs/eddi/engine/internal/groups/GroupConversationSchemaMigrations.java b/src/main/java/ai/labs/eddi/engine/internal/groups/GroupConversationSchemaMigrations.java index feddd2669e..4920431d58 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/groups/GroupConversationSchemaMigrations.java +++ b/src/main/java/ai/labs/eddi/engine/internal/groups/GroupConversationSchemaMigrations.java @@ -28,13 +28,16 @@ private GroupConversationSchemaMigrations() { /** * Migration functions, keyed by the version they upgrade from (so * entry {@code N} takes a version-{@code N} document to version {@code N+1}). - * Empty today — {@link GroupConversation#CURRENT_SCHEMA_VERSION} is {@code 1}, - * the first version that has ever existed, so there is nothing yet to migrate - * from. Every future Wave item that adds a resume-relevant field bumps - * {@code CURRENT_SCHEMA_VERSION} and registers its own entry here. A version - * hop with no registered entry defaults to identity (see - * {@link #prepareForResume}) — the documented, common case for a bump whose new - * fields default correctly via Jackson without any transformation. + * Empty today: every bump so far ({@code 1→2→3}) was additive — the new fields + * default correctly via Jackson, so those hops ride the identity fallback below + * rather than a registered entry. That fallback path has therefore already been + * exercised; do not read the empty map as "no version has ever changed". Every + * future Wave item that adds a resume-relevant field bumps + * {@link GroupConversation#CURRENT_SCHEMA_VERSION} and registers an entry here + * only when the hop actually needs a transformation. Documents stored without a + * {@code schemaVersion} key deserialize claiming + * {@link GroupConversation#LEGACY_SCHEMA_VERSION} and enter the ladder at the + * bottom. */ private static final Map> MIGRATIONS = Map.of(); diff --git a/src/main/java/ai/labs/eddi/engine/internal/groups/GroupCostLedger.java b/src/main/java/ai/labs/eddi/engine/internal/groups/GroupCostLedger.java index cece3a8405..5da9a42f55 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/groups/GroupCostLedger.java +++ b/src/main/java/ai/labs/eddi/engine/internal/groups/GroupCostLedger.java @@ -22,17 +22,13 @@ * {@code memberCosts}/{@code totalCost} (Wave 0, F5), and enforces * {@code ProtocolConfig#maxCostPerDiscussion} against it (I1). *

    - * Known gap (V1, confirmed): {@link MemoryKeys#AUDIT_COST} — the value - * this class reads — is written by {@code LlmTask.accumulateCost} from - * {@code cascadeCostUsd + toolCostUsd} only. There is no dollar price table for - * a non-cascade model completion anywhere in this codebase today - * ({@code LlmTask}'s own Javadoc says so directly), so a member's own turn - * contributes {@code $0} here unless the group's LLM config uses the - * multi-model cascade (priced) or the turn called a priced tool. This is a - * real, known undercount for the common case, not a bug in this class — I1 is - * where model-call cost recording gets added, reusing whatever price source it - * settles on. Until then, {@code totalCost} is a lower bound, not the true - * total. + * Coverage note: {@link MemoryKeys#AUDIT_COST} — the value this class + * reads — sums the member conversation's LLM token spend (cascade step pricing, + * or the task-level {@code inputPricePer1M}/{@code outputPricePer1M} for plain + * calls) and its tracked tool cost. Pricing is config-driven with no built-in + * provider price table, so a member whose LLM config carries no prices still + * contributes {@code $0} of token cost here — for such members + * {@code totalCost} remains a lower bound. *

    * Both {@code memberCosts} and the recomputed {@code totalCost} are set, never * incremented by a delta — each call replaces the member's entry with its @@ -91,12 +87,41 @@ public static void accumulateMemberCost(GroupConversation gc, String attribution * this group's attribution, whole — the child's own {@code totalCost} already * reflects everything its own members accumulated (recursively, via this same * method for individual members). + *

    + * Keyed per child discussion, not per member: {@code memberCosts} + * records by replacement, which is only idempotent when one key means one + * conversation's cumulative cost. Every turn of a GROUP member spawns a fresh + * child discussion whose {@code totalCost} starts at 0, so keying by agentId + * alone replaced child N−1's whole spend with child N's and only the last child + * survived the sum. The suffix restores the map's invariant; a child without an + * id (not yet persisted) falls back to the plain agentId. */ public static void accumulateNestedGroupCost(GroupConversation gc, GroupMember member, GroupConversation subConversation) { if (subConversation == null) { return; } - recordAndReSum(gc, member.agentId(), subConversation.getTotalCost()); + String attributionKey = subConversation.getId() != null + ? member.agentId() + ":" + subConversation.getId() + : member.agentId(); + recordAndReSum(gc, attributionKey, subConversation.getTotalCost()); + } + + /** + * Attribution for spend the discussion's own machinery incurs — today the I9 + * transcript summarizer. {@code key} must be unique per priced operation (e.g. + * {@code "system:summarizer:full:42"}, suffixed with the boundary it covered + * through): the map records by replacement, so a re-run of the same operation + * is idempotent while distinct operations sum. + */ + public static void recordSystemCost(GroupConversation gc, String key, double cost) { + // !(cost > 0.0) rather than cost <= 0.0: NaN fails every comparison, so + // the latter would let a NaN through, poison totalCost, and silently + // disable the ceiling checks (NaN comparisons are all false). Infinity is + // rejected for the same reason — no finite ceiling could ever bind again. + if (key == null || !Double.isFinite(cost) || !(cost > 0.0)) { + return; + } + recordAndReSum(gc, key, cost); } /** diff --git a/src/main/java/ai/labs/eddi/engine/internal/groups/GroupHitlCoordinator.java b/src/main/java/ai/labs/eddi/engine/internal/groups/GroupHitlCoordinator.java index a618a5db13..0f2e06a604 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/groups/GroupHitlCoordinator.java +++ b/src/main/java/ai/labs/eddi/engine/internal/groups/GroupHitlCoordinator.java @@ -32,6 +32,7 @@ import ai.labs.eddi.engine.schedule.IScheduleStore; import ai.labs.eddi.engine.schedule.model.ScheduleConfiguration; import ai.labs.eddi.engine.security.CallerIdentityContext; +import ai.labs.eddi.utils.LogSanitizer; import io.micrometer.core.instrument.Counter; import org.jboss.logging.Logger; @@ -288,7 +289,9 @@ public void convertPauseToCancelIfSignalled(GroupConversation gc, GroupDiscussio */ public void removeTokenAndConvertIfSignalled(GroupConversation gc, GroupDiscussionEventListener listener) { var removed = activeTokens.remove(gc.getId()); - if (removed != null && removed.isCancelled() && gc.getState() == GroupConversationState.AWAITING_APPROVAL) { + if (removed != null && removed.isCancelled() + && (gc.getState() == GroupConversationState.AWAITING_APPROVAL + || gc.getState() == GroupConversationState.AWAITING_HUMAN_INPUT)) { convertPauseToCancelIfSignalled(gc, listener, removed); } } @@ -298,24 +301,32 @@ public void convertPauseToCancelIfSignalled(GroupConversation gc, GroupDiscussio if (token == null || !token.isCancelled()) { return; } + // I6: the conversion serves BOTH pause kinds — CAS on whichever paused + // state this leg just committed (approval or human turn). + final GroupConversationState pausedState = gc.getState() == GroupConversationState.AWAITING_HUMAN_INPUT + ? GroupConversationState.AWAITING_HUMAN_INPUT + : GroupConversationState.AWAITING_APPROVAL; // Only the persist itself may revert the in-memory state. Past the commit - // below, CANCELLED is durable, and reverting memory to AWAITING_APPROVAL + // below, CANCELLED is durable, and reverting memory to the paused state // would make executeDiscussion's finally block read the wrong state: it - // skips signingGuard.forgetConversation for AWAITING_APPROVAL (leaking the + // skips signingGuard.forgetConversation for paused states (leaking the // verification cursor) and only runs cleanupEphemeralAgents for // FAILED/CANCELLED — so dynamically created agents would stay deployed. // The realistic post-commit thrower is the listener: an SSE sink on a closed // stream. It used to sit inside this try. + final GroupConversation.PendingHumanInput savedPending = gc.getPendingHumanInput(); try { gc.setState(GroupConversationState.CANCELLED); gc.setPausedAt(null); + gc.setPendingHumanInput(null); gc.setLastModified(Instant.now()); - conversationStore.updateIfState(gc, GroupConversationState.AWAITING_APPROVAL); + conversationStore.updateIfState(gc, pausedState); } catch (IResourceStore.ResourceModifiedException e) { // Someone else moved the state concurrently (approve/timeout) — restore // the in-memory state so the executeDiscussion finally block does not // release paused-state resources for a conversation still paused in DB. - gc.setState(GroupConversationState.AWAITING_APPROVAL); + gc.setState(pausedState); + gc.setPendingHumanInput(savedPending); LOGGER.infof("Pause→cancel conversion for GC %s lost a state race — leaving persisted state", gc.getId()); return; } catch (IGroupConversationStore.GroupConversationGoneException e) { @@ -323,7 +334,8 @@ public void convertPauseToCancelIfSignalled(GroupConversation gc, GroupDiscussio LOGGER.infof("Pause→cancel conversion for GC %s skipped — conversation was deleted", gc.getId()); return; } catch (Exception e) { - gc.setState(GroupConversationState.AWAITING_APPROVAL); + gc.setState(pausedState); + gc.setPendingHumanInput(savedPending); LOGGER.warnf("Failed to convert just-committed pause of GC %s to CANCELLED: %s", gc.getId(), e.getMessage()); return; @@ -441,9 +453,11 @@ public boolean cancelDiscussion(String conversationId, ControlSignal mode) LOGGER.infof("Cancel skipped: GC %s already in terminal state %s", conversationId, state); return false; } - boolean wasPaused = state == GroupConversationState.AWAITING_APPROVAL; + boolean wasPaused = state == GroupConversationState.AWAITING_APPROVAL + || state == GroupConversationState.AWAITING_HUMAN_INPUT; gc.setState(GroupConversationState.CANCELLED); gc.setPausedAt(null); // keep isPaused() consistent with the terminal state + gc.setPendingHumanInput(null); // I6: a cancelled turn is no longer owed gc.setLastModified(Instant.now()); try { conversationStore.updateIfState(gc, state); @@ -713,7 +727,10 @@ public GroupConversation resumeDiscussion(String groupConversationId, GroupAppro throw new IResourceStore.ResourceNotFoundException("Group not found."); } groupConfig = groupStore.read(groupId, currentGroupId.getVersion()); - phases = groupConversationService.resolvePhases(groupConfig); + // I12: a facilitator-diverged runtime phase list wins over the + // config's — the bookmark below was taken against IT, and the + // drift guard must compare like with like. + phases = groupConversationService.effectivePhases(gc, groupConfig); // Phase 5f: Config drift guard — verify the phase at the bookmark // still matches what was paused. If the config was edited while the @@ -762,7 +779,16 @@ public GroupConversation resumeDiscussion(String groupConversationId, GroupAppro // the pause instead, mirroring the phase-name drift branch above. // Independent of savedPhaseName: a resumePoint's own phaseIdx is the // authority for which phase's roster to check. - if (savedResumePoint != null) { + // I6 scoping: this guard's ">= size means drift" arithmetic is the + // APPROVAL bookmark's contract (the bookmarked speaker itself + // re-runs, so its index must exist). Human-turn bookmarks cannot + // reach this method — their state is AWAITING_HUMAN_INPUT, which + // the CAS above rejects — and their advanced (speakerIdx+1) + // semantics would false-positive here at the last-speaker + // boundary; the executors clamp their indices safely instead. + if (savedResumePoint != null + && !GroupConversation.RESUME_KIND_HUMAN_TURN.equals(savedResumePoint.pauseKind()) + && !GroupConversation.RESUME_KIND_HUMAN_TURN_PARALLEL.equals(savedResumePoint.pauseKind())) { List currentSpeakers = savedResumePoint.phaseIdx() < phases.size() // I7: the same roster the phase loop will use, recruits // included — resolving against the config alone would @@ -828,9 +854,12 @@ public GroupConversation resumeDiscussion(String groupConversationId, GroupAppro // Executor saturated/shut down — no thread will run the resume. The CAS // above already consumed the pause; restore it so the approval remains // actionable instead of leaving an IN_PROGRESS zombie. - activeTokens.remove(gc.getId()); restoreGroupPause(gc, savedPhaseIndex, savedPhaseName, savedPauseType, savedPausedAt, null, savedTimeoutPolicy, savedApprovalTimeout); + // Remove-and-recheck like every other rollback path — a plain remove + // here dropped a cancel signalled between token registration and this + // rollback, leaving a "cancelled" discussion stuck AWAITING_APPROVAL. + removeTokenAndConvertIfSignalled(gc, listener); throw new IResourceStore.ResourceStoreException( "Failed to submit resumed group discussion: " + e.getMessage(), e); } @@ -904,6 +933,406 @@ public void restoreGroupPause(GroupConversation gc, int phaseIndex, String phase } } + // ================================================================= + // I6 — human member turns: pause commit, submission, timeout + // ================================================================= + + /** + * Upper bound on one human submission — transcripts are documents, not blobs. + */ + static final int MAX_HUMAN_INPUT_LENGTH = 100_000; + + /** + * Commits an {@code AWAITING_HUMAN_INPUT} pause for a HUMAN member's turn (I6). + * The commitPause sibling, deliberately NOT folded into it: the states, the + * pause payloads, the timeout policies and the resolution surfaces all differ — + * sharing a method body would couple what the design keeps apart. + * + * @param turnCountIncludingThisTurn + * the leg's turn count PLUS ONE for the human turn being paused on — + * the turn is spent when it resolves (submission or SKIP_TURN), and + * the resumed leg's counter is seeded from this bookmark, so + * counting it here is what keeps a human turn from being free + * @param entryTypeName + * {@link TranscriptEntryType} name the eventual submission is + * recorded as — the phase's natural type, captured now so a config + * edit while paused cannot re-type the entry + */ + public void commitHumanTurnPause(GroupConversation gc, int phaseIdx, DiscussionPhase phase, int repeatIdx, + PhaseExecutionEngine.HumanTurnRequired turn, int turnCountIncludingThisTurn, + String entryTypeName, GroupDiscussionEventListener listener, + AgentGroupConfiguration config) + throws IResourceStore.ResourceStoreException { + GroupMember member = turn.member(); + var humanConfig = config != null && config.getHumanMemberConfig() != null + ? config.getHumanMemberConfig() + : new AgentGroupConfiguration.HumanMemberConfig(); + + gc.setResumePoint(new GroupConversation.ResumePoint(phaseIdx, repeatIdx, turn.speakerIdx(), + turn.parallel() + ? GroupConversation.RESUME_KIND_HUMAN_TURN_PARALLEL + : GroupConversation.RESUME_KIND_HUMAN_TURN)); + gc.setPendingHumanInput(new GroupConversation.PendingHumanInput( + member.agentId(), member.displayName(), phaseIdx, repeatIdx, turn.speakerIdx(), + entryTypeName, turn.renderedPrompt(), humanConfig.onTimeout().name(), Instant.now())); + gc.setState(GroupConversationState.AWAITING_HUMAN_INPUT); + gc.setPausedAt(Instant.now()); + gc.setPausedAtPhaseIndex(phaseIdx); + gc.setPausedPhaseName(phase.name()); + gc.setPausedTurnCount(turnCountIncludingThisTurn); + gc.setHitlPauseType(HitlPauseType.HUMAN_TURN); + gc.setHitlPauseReason("Waiting for input from " + member.displayName() + " — phase: " + phase.name()); + // The ISO-8601 duration rides the shared bookmark field (REST visibility + + // crash-recovery re-arm); the SKIP_TURN/ABORT policy rides the pending + // record — it is not a HitlTimeoutPolicy and must not pretend to be one. + gc.setHitlApprovalTimeout(humanConfig.turnTimeout()); + gc.setHitlTimeoutPolicy(null); + conversationStore.update(gc); + + scheduleHumanTurnTimeout(gc); + counterGroupHitlPause.increment(); + + if (listener != null) { + listener.onHumanInputRequested(new GroupConversationEventSink.HumanInputRequestedEvent( + member.agentId(), member.displayName(), phaseIdx, phase.name())); + } + LOGGER.infof("Group discussion %s paused for human input from member '%s' at phase %d", + LogSanitizer.sanitize(gc.getId()), LogSanitizer.sanitize(member.agentId()), phaseIdx); + } + + /** + * Commits an {@code AWAITING_HUMAN_INPUT} pause for a facilitator's + * ESCALATE_HUMAN move (I12), riding I6's pending-input machinery whole: the + * same state, the same submission endpoint and access guard (the configured + * principal or an admin), the same timeout schedule, the same inbox surface. + *

    + * What differs is the bookmark's shape: an escalation is not a speaker's turn, + * so it points at where the discussion RESUMES — the next repeat (mid-phase + * checkpoint) or the next phase (boundary checkpoint) — with {@code + * speakerIdx = -1}, which the shared resolution path's {@code +1} advance turns + * into "start at speaker 0". The answer records as a peer-visible + * {@code FOLLOW_UP} entry: injected human guidance is meant to be read, unlike + * the peer-hidden FACILITATION bookkeeping around it. + * + * @param phaseList + * the RUNTIME phase list the loop is executing — the resume phase's + * name is read from it, never from the config, because the + * facilitator may have just diverged the two (a CALL_VOTE then an + * immediate escalation is the concrete case) + */ + public void commitFacilitatorEscalationPause(GroupConversation gc, int resumePhaseIdx, int resumeRepeatIdx, + List phaseList, + FacilitatorEngine.FacilitatorAction.Escalation escalation, + int turnCountIncludingThisTurn, GroupDiscussionEventListener listener, + AgentGroupConfiguration config) + throws IResourceStore.ResourceStoreException { + var humanConfig = config != null && config.getHumanMemberConfig() != null + ? config.getHumanMemberConfig() + : new AgentGroupConfiguration.HumanMemberConfig(); + DiscussionPhase resumePhase = phaseList.get(resumePhaseIdx); + + gc.setResumePoint(new GroupConversation.ResumePoint(resumePhaseIdx, resumeRepeatIdx, -1, + GroupConversation.RESUME_KIND_HUMAN_TURN)); + gc.setPendingHumanInput(new GroupConversation.PendingHumanInput( + escalation.principalId(), escalation.principalId(), resumePhaseIdx, resumeRepeatIdx, -1, + TranscriptEntryType.FOLLOW_UP.name(), escalation.question(), humanConfig.onTimeout().name(), + Instant.now())); + gc.setState(GroupConversationState.AWAITING_HUMAN_INPUT); + gc.setPausedAt(Instant.now()); + gc.setPausedAtPhaseIndex(resumePhaseIdx); + gc.setPausedPhaseName(resumePhase.name()); + gc.setPausedTurnCount(turnCountIncludingThisTurn); + gc.setHitlPauseType(HitlPauseType.HUMAN_TURN); + gc.setHitlPauseReason("Facilitator escalation — waiting for input from " + escalation.principalId()); + gc.setHitlApprovalTimeout(humanConfig.turnTimeout()); + gc.setHitlTimeoutPolicy(null); + conversationStore.update(gc); + + scheduleHumanTurnTimeout(gc); + counterGroupHitlPause.increment(); + + if (listener != null) { + listener.onHumanInputRequested(new GroupConversationEventSink.HumanInputRequestedEvent( + escalation.principalId(), escalation.principalId(), resumePhaseIdx, resumePhase.name())); + } + LOGGER.infof("Group discussion %s paused on a facilitator escalation to '%s' (resumes at phase %d)", + LogSanitizer.sanitize(gc.getId()), LogSanitizer.sanitize(escalation.principalId()), resumePhaseIdx); + } + + /** + * One-shot timeout schedule for a human turn. Same schedule NAME as the + * approval timeout (one armed timeout per conversation, ever), but surface + * {@code group-human} and a SKIP_TURN/ABORT policy string — the fire handler + * branches on the surface before it ever parses a {@code HitlTimeoutPolicy}. + */ + public void scheduleHumanTurnTimeout(GroupConversation gc) { + try { + var pending = gc.getPendingHumanInput(); + String timeoutStr = gc.getHitlApprovalTimeout(); + if (pending == null || timeoutStr == null || timeoutStr.isBlank()) { + return; // wait indefinitely + } + Duration timeout = Duration.parse(timeoutStr); + Instant pausedAt = gc.getPausedAt(); + Instant now = Instant.now(); + Instant fireAt = pausedAt != null ? pausedAt.plus(timeout) : now.plus(timeout); + if (fireAt.isBefore(now)) { + fireAt = now.plus(GROUP_HITL_REARM_GRACE); + } + + var schedule = new ScheduleConfiguration(); + schedule.setName(HitlSchedules.groupTimeoutScheduleName(gc.getId())); + schedule.setEnabled(true); + schedule.setOneTimeAt(fireAt.toString()); + schedule.setNextFire(fireAt); + schedule.setCreatedAt(Instant.now()); + schedule.setMetadata(Map.of( + HitlSchedules.METADATA_TYPE_KEY, HitlSchedules.METADATA_TYPE_TIMEOUT, + HitlSchedules.METADATA_POLICY_KEY, pending.onTimeout(), + HitlSchedules.METADATA_SURFACE_KEY, HitlSchedules.SURFACE_GROUP_HUMAN, + HitlSchedules.METADATA_CONVERSATION_ID_KEY, gc.getId())); + scheduleStore.createSchedule(schedule); + LOGGER.infof("Scheduled human-turn timeout for %s at %s (policy: %s)", + LogSanitizer.sanitize(gc.getId()), fireAt, LogSanitizer.sanitize(pending.onTimeout())); + } catch (Exception e) { + LOGGER.warnf("Failed to schedule human-turn timeout for %s: %s", + LogSanitizer.sanitize(gc.getId()), LogSanitizer.sanitize(e.getMessage())); + } + } + + /** + * Records a HUMAN member's submitted response and resumes the discussion (I6). + * Validation and the config-drift check run BEFORE any mutation, so a stale + * bookmark refuses the submission instead of needing a rollback; the one + * failure after the CAS (executor saturation) rolls the append back and + * restores the pause. + */ + public GroupConversation submitHumanInput(String groupConversationId, String memberId, String content, + String submittedBy, GroupDiscussionEventListener listener) + throws GroupDiscussionException, IResourceStore.ResourceStoreException, + IResourceStore.ResourceNotFoundException, IResourceStore.ResourceModifiedException { + var gc = GroupConversationSchemaMigrations.prepareForResume(conversationStore.read(groupConversationId)); + if (gc.getState() != GroupConversationState.AWAITING_HUMAN_INPUT) { + throw new GroupDiscussionException("Group conversation is not awaiting human input"); + } + var pending = gc.getPendingHumanInput(); + if (pending == null) { + throw new GroupDiscussionException( + "Group conversation is awaiting human input but records no pending turn — cancel the discussion"); + } + if (memberId == null || !memberId.equals(pending.memberId())) { + throw new IllegalArgumentException( + "This conversation is waiting on member '" + pending.memberId() + "'"); + } + if (content == null || content.isBlank()) { + throw new IllegalArgumentException("Content must not be blank"); + } + if (content.length() > MAX_HUMAN_INPUT_LENGTH) { + throw new IllegalArgumentException( + "Content exceeds the maximum length of " + MAX_HUMAN_INPUT_LENGTH + " characters"); + } + return resolveHumanTurn(gc, pending, content, null, submittedBy, listener); + } + + /** + * SKIP_TURN timeout resolution: the turn resolves to a SKIPPED entry ("no + * response within the configured window") and the discussion moves on — the + * same resume path a submission takes, with a different entry. + */ + public void skipHumanTurnOnTimeout(String groupConversationId) { + try { + var gc = GroupConversationSchemaMigrations.prepareForResume(conversationStore.read(groupConversationId)); + if (gc.getState() != GroupConversationState.AWAITING_HUMAN_INPUT || gc.getPendingHumanInput() == null) { + LOGGER.infof("Human-turn timeout for %s skipped — no longer awaiting human input", groupConversationId); + return; + } + var pending = gc.getPendingHumanInput(); + String reason = "No response from " + pending.displayName() + + (gc.getHitlApprovalTimeout() != null ? " within " + gc.getHitlApprovalTimeout() : ""); + resolveHumanTurn(gc, pending, null, reason, "system:timeout", null); + LOGGER.infof("Human turn for %s timed out (SKIP_TURN) — member '%s' skipped", + groupConversationId, pending.memberId()); + } catch (Exception e) { + LOGGER.errorf(e, "Failed to skip timed-out human turn for %s", groupConversationId); + } + } + + /** + * The shared resolution path: append the turn's entry (the submission, or a + * SKIPPED record), advance the bookmark past the answered speaker, CAS out of + * AWAITING_HUMAN_INPUT, and re-enter the discussion on a background thread. + */ + private GroupConversation resolveHumanTurn(GroupConversation gc, GroupConversation.PendingHumanInput pending, + String content, String skipReason, String resolvedBy, + GroupDiscussionEventListener listener) + throws GroupDiscussionException, IResourceStore.ResourceStoreException, + IResourceStore.ResourceNotFoundException, IResourceStore.ResourceModifiedException { + String groupConversationId = gc.getId(); + + // Config + drift check BEFORE any mutation. + IResourceStore.IResourceId currentGroupId = groupStore.getCurrentResourceId(gc.getGroupId()); + if (currentGroupId == null) { + throw new IResourceStore.ResourceNotFoundException("Group not found."); + } + AgentGroupConfiguration groupConfig = groupStore.read(gc.getGroupId(), currentGroupId.getVersion()); + // I12: prefer the runtime phase list — a facilitator escalation can pause + // into a phase the config does not contain (a just-inserted vote), and the + // drift check below would otherwise refuse every submission for it. + List phases = groupConversationService.effectivePhases(gc, groupConfig); + GroupConversation.ResumePoint bookmark = gc.getResumePoint(); + if (bookmark == null || bookmark.phaseIdx() != pending.phaseIdx()) { + throw new GroupDiscussionException( + "The paused turn's bookmark is missing or inconsistent — cancel the discussion"); + } + if (pending.phaseIdx() >= phases.size() + || (gc.getPausedPhaseName() != null + && !gc.getPausedPhaseName().equals(phases.get(pending.phaseIdx()).name()))) { + throw new GroupDiscussionException( + "Group config changed while paused — the bookmarked phase no longer matches; fix the config or cancel"); + } + + TranscriptEntry entry; + if (skipReason != null) { + entry = new TranscriptEntry(pending.memberId(), pending.displayName(), null, pending.phaseIdx(), + phases.get(pending.phaseIdx()).name(), TranscriptEntryType.SKIPPED, Instant.now(), skipReason, null); + } else { + TranscriptEntryType entryType; + try { + entryType = TranscriptEntryType.valueOf(pending.entryType()); + } catch (Exception e) { + // hand-edited or legacy — a mis-typed entry beats a refused turn + LOGGER.warnf("Pending human turn for %s carries unknown entry type '%s' — recording as OPINION", + groupConversationId, pending.entryType()); + entryType = TranscriptEntryType.OPINION; + } + entry = new TranscriptEntry(pending.memberId(), pending.displayName(), content, pending.phaseIdx(), + phases.get(pending.phaseIdx()).name(), entryType, Instant.now(), null, null); + } + + // Saved for the one rollback path below (executor saturation). + final Instant savedPausedAt = gc.getPausedAt(); + final String savedPausedPhaseName = gc.getPausedPhaseName(); + final int savedPausedPhaseIdx = gc.getPausedAtPhaseIndex(); + final String savedApprovalTimeout = gc.getHitlApprovalTimeout(); + final String savedPauseReason = gc.getHitlPauseReason(); + + gc.getTranscript().add(entry); + // Advance past the answered speaker; RESUME_KIND is preserved so a + // parallel tail resumes as a parallel tail. + gc.setResumePoint(new GroupConversation.ResumePoint(pending.phaseIdx(), pending.repeatIdx(), + pending.speakerIdx() + 1, bookmark.pauseKind())); + gc.setPendingHumanInput(null); + gc.setPausedAt(null); + gc.setPausedAtPhaseIndex(-1); + gc.setPausedPhaseName(null); + gc.setHitlPauseType(null); + gc.setHitlPauseReason(null); + gc.setHitlTimeoutPolicy(null); + gc.setHitlApprovalTimeout(null); + gc.setState(GroupConversationState.IN_PROGRESS); + gc.setLastModified(Instant.now()); + // Double-submit / concurrent-cancel race → ResourceModifiedException → 409. + conversationStore.updateIfState(gc, GroupConversationState.AWAITING_HUMAN_INPUT); + + // Same post-CAS order as resumeDiscussion: token first (a racing cancel + // must find a signalable token), then the schedule. The metric, the audit + // entry and the resume event are deliberately deferred until the resume is + // actually ENQUEUED below — a submit failure rolls the pause back, and a + // rolled-back attempt must not pollute the resume metric or the EU-AI-Act + // audit trail (the same rule resumeDiscussion follows). + activeTokens.put(gc.getId(), new DiscussionControlToken()); + deleteGroupHitlTimeoutSchedule(groupConversationId); + + final int startFromPhase = pending.phaseIdx(); + var question = gc.getResumeQuestion() != null ? gc.getResumeQuestion() : gc.getOriginalQuestion(); + Runnable resumeWork = () -> { + try { + groupConversationService.executeDiscussion(gc, groupConfig, phases, question, listener, startFromPhase); + } catch (Exception e) { + // executeDiscussion persisted the terminal state itself. + LOGGER.errorf(e, "Resumed group discussion %s failed after human turn resolution", groupConversationId); + } + }; + try { + executorService.submit(callerIdentityContext.withIdentity(callerIdentityContext.captureOrCurrent(), resumeWork)); + } catch (RuntimeException e) { + // No thread will run the resume — roll the append back and restore the + // pause, so the member's turn is not silently swallowed. + LOGGER.errorf(e, "Could not schedule resumed discussion %s after human turn — restoring the pause", + groupConversationId); + synchronized (gc.getTranscript()) { + gc.getTranscript().remove(entry); + } + gc.setResumePoint(bookmark); + gc.setPendingHumanInput(pending); + gc.setState(GroupConversationState.AWAITING_HUMAN_INPUT); + gc.setPausedAt(savedPausedAt); + gc.setPausedAtPhaseIndex(savedPausedPhaseIdx); + gc.setPausedPhaseName(savedPausedPhaseName); + gc.setHitlPauseType(HitlPauseType.HUMAN_TURN); + gc.setHitlPauseReason(savedPauseReason); + gc.setHitlApprovalTimeout(savedApprovalTimeout); + gc.setLastModified(Instant.now()); + try { + conversationStore.updateIfState(gc, GroupConversationState.IN_PROGRESS); + scheduleHumanTurnTimeout(gc); + } catch (Exception restoreEx) { + LOGGER.errorf(restoreEx, "Failed to restore human pause for %s", groupConversationId); + } + // Remove-and-recheck, not a plain remove: a cancel signalled between + // the token registration above and this rollback would otherwise be + // dropped with the discarded token, leaving a "cancelled" discussion + // restored to AWAITING_HUMAN_INPUT with an armed timer. + removeTokenAndConvertIfSignalled(gc, listener); + throw new GroupDiscussionException("Could not schedule the resumed discussion — try again", e); + } + + // The resume is committed and enqueued — now count it, audit it, and tell + // SSE subscribers the discussion is live again. + counterGroupHitlResume.increment(); + auditHumanTurnResolution(gc, pending, resolvedBy, skipReason != null); + if (listener != null) { + listener.onHitlResume(new GroupConversationEventSink.HitlResumeEvent( + skipReason != null ? "HUMAN_TURN_SKIPPED" : "HUMAN_INPUT", null, resolvedBy)); + } + + // A freshly-read copy, not the live instance: resumeWork mutates gc on a + // background thread (transcript appends, cost maps) while the caller + // serializes the return value — handing back the live object is a + // serialization-time race. + try { + return conversationStore.read(groupConversationId); + } catch (Exception e) { + LOGGER.debugf("Post-resume re-read of %s failed (%s) — returning the pre-resume snapshot state", + groupConversationId, e.getMessage()); + return gc; + } + } + + /** Audit entry for a human turn's resolution (submission or timeout skip). */ + private void auditHumanTurnResolution(GroupConversation gc, GroupConversation.PendingHumanInput pending, + String resolvedBy, boolean skipped) { + if (auditLedgerService == null || !auditLedgerService.isEnabled()) { + return; + } + try { + var detail = new LinkedHashMap(); + detail.put("verdict", skipped ? "HUMAN_TURN_SKIPPED" : "HUMAN_INPUT_SUBMITTED"); + detail.put("memberId", pending.memberId()); + detail.put("decidedBy", resolvedBy != null ? resolvedBy : "unknown"); + detail.put("automated", resolvedBy != null && resolvedBy.startsWith("system:")); + detail.put("surface", "group"); + auditLedgerService.submit(new AuditEntry( + UUID.randomUUID().toString(), gc.getId(), gc.getGroupId(), null, gc.getUserId(), + null, -1, "hitl.approval", "hitl", -1, 0L, + Map.of(), detail, null, null, List.of(), 0.0, + Instant.now(), null, null)); + } catch (Exception e) { + LOGGER.warnf("Failed to submit human-turn audit entry for group conversation %s: %s", + gc.getId(), e.getMessage()); + } + } + /** * Submits an {@code hitl.approval} audit entry for a group HITL decision (#15, * EU AI Act). Covers human and automated timeout decisions. diff --git a/src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java b/src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java index f028795881..053ef07971 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java +++ b/src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java @@ -8,6 +8,7 @@ import ai.labs.eddi.configs.deployment.IDeploymentStore; import ai.labs.eddi.configs.groups.IAgentGroupStore; import ai.labs.eddi.configs.groups.IGroupConversationStore; +import ai.labs.eddi.configs.groups.ISharedArtifactStore; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.DiscussionPhase; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.LifecyclePolicy; @@ -36,6 +37,8 @@ import org.jboss.logging.Logger; import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -83,6 +86,7 @@ public class GroupLifecycleOps { private final IAgentFactory agentFactory; private final IAgentStore agentStore; private final IDeploymentStore deploymentStore; + private final ISharedArtifactStore sharedArtifactStore; private final Set operationsInProgress; private final ConcurrentHashMap activeTokens; private final GroupConversationService groupConversationService; @@ -93,7 +97,7 @@ public class GroupLifecycleOps { public GroupLifecycleOps(IGroupConversationStore conversationStore, IAgentGroupStore groupStore, IConversationService conversationService, IAgentFactory agentFactory, IAgentStore agentStore, - IDeploymentStore deploymentStore, Set operationsInProgress, + IDeploymentStore deploymentStore, ISharedArtifactStore sharedArtifactStore, Set operationsInProgress, ConcurrentHashMap activeTokens, GroupConversationService groupConversationService, Counter counterGroupFollowUp, Counter counterGroupContinue, Counter counterGroupClose, Counter counterGroupFailure) { @@ -103,6 +107,7 @@ public GroupLifecycleOps(IGroupConversationStore conversationStore, IAgentGroupS this.agentFactory = agentFactory; this.agentStore = agentStore; this.deploymentStore = deploymentStore; + this.sharedArtifactStore = sharedArtifactStore; this.operationsInProgress = operationsInProgress; this.activeTokens = activeTokens; this.groupConversationService = groupConversationService; @@ -138,7 +143,8 @@ public void deleteGroupConversation(String groupConversationId) // schedule fires against a deleted conversation, ephemeral dynamic // agents stay deployed forever, and the signing guard's verification // cursor leaks. - if (gc.getState() == GroupConversationState.AWAITING_APPROVAL) { + if (gc.getState() == GroupConversationState.AWAITING_APPROVAL + || gc.getState() == GroupConversationState.AWAITING_HUMAN_INPUT) { groupConversationService.deleteGroupHitlTimeoutSchedule(groupConversationId); groupConversationService.cleanupAfterTerminalState(gc); } @@ -153,6 +159,10 @@ public void deleteGroupConversation(String groupConversationId) // operations. Delete is terminal, so reclaim any dynamically-created agents // here; otherwise deleting a COMPLETED conversation would orphan them. cleanupEphemeralAgentsForGroup(gc); + // I17: artifacts live in their own collection keyed by this conversation + // — remove them with their discussion, before the document goes (while + // the id is still provably a discussion the caller could delete). + deleteArtifactsForGroupConversation(groupConversationId); conversationStore.delete(groupConversationId); } catch (IResourceStore.ResourceNotFoundException e) { LOGGER.warnf("Group conversation %s not found for deletion", LogSanitizer.sanitize(groupConversationId)); @@ -364,6 +374,19 @@ public GroupConversation continueDiscussion(String groupConversationId, String q // and had this round's dissents merged onto them. gc.setSynthesizedAnswer(null); gc.setDecision(null); + // I12: same defensive class as the two fields above — completion already + // clears the facilitator's one-off phase divergence, but a continuation + // must never run round N+1 against round N's inserted phases or count + // its extensions against stale phase indices. + gc.setRuntimePhases(null); + gc.clearFacilitatorExtensions(); + // I11 (final-review finding): the negotiation table is a ROUND-scoped + // conclusion like the two fields above. Left in place, round 2 of a + // NEGOTIATION group runs against round 1's proposals — a single fresh + // acceptance can then reach "unanimous agreement" on signatures cast + // for a DIFFERENT question, and the signed-acceptance indices in the + // decision would even point at round 1's transcript entries. + gc.setNegotiation(null); gc.setResumeQuestion(question); gc.getTranscript().add(new TranscriptEntry( "user", "User", question, 0, "Question", @@ -476,6 +499,11 @@ public GroupConversation closeGroupConversation(String groupConversationId) // Ephemeral agent cleanup (deferred from executeDiscussion) cleanupEphemeralAgentsForGroup(gc); + // I17: close is a lifecycle end — the working artifacts go with it. + // Their durable trace is the transcript (accepted updates are announced + // there and a synthesis quotes what mattered), not the working copies. + deleteArtifactsForGroupConversation(groupConversationId); + LOGGER.infof("Group conversation %s closed — member conversations ended, ephemeral agents cleaned up", LogSanitizer.sanitize(groupConversationId)); @@ -486,13 +514,46 @@ public GroupConversation closeGroupConversation(String groupConversationId) } } + /** + * I17 cascade: removes the discussion's shared artifacts. Warn-and-continue on + * failure — a broken artifact store must not make discussions undeletable; the + * user-keyed GDPR erasure sweep (which fails loudly) is the completeness + * guarantee, this is the tidy path. + */ + private void deleteArtifactsForGroupConversation(String groupConversationId) { + if (sharedArtifactStore == null) { + return; + } + try { + long removed = sharedArtifactStore.deleteByGroupConversationId(groupConversationId); + if (removed > 0) { + LOGGER.infof("Removed %d shared artifact(s) of group conversation %s", removed, LogSanitizer.sanitize(groupConversationId)); + } + } catch (Exception e) { + LOGGER.warnf("Failed to remove shared artifacts of group conversation %s: %s", + LogSanitizer.sanitize(groupConversationId), e.getMessage()); + } + } + public List listGroupPendingApprovals(String groupId, int limit) throws IResourceStore.ResourceStoreException { // Bounded summaries — never hand full transcripts to a listing endpoint. // The groupId filter is applied in the QUERY (not post-limit), so a busy // deployment cannot push this group's items past the limit window. int clamped = Math.max(1, Math.min(limit, 1000)); - return conversationStore.findByState(GroupConversationState.AWAITING_APPROVAL, groupId, clamped).stream() + // I6: pending human turns join the SAME inbox — pauseType "HUMAN_TURN" + // (plus pendingMemberId) is the kind discriminator; no third inbox. Both + // states are queried with the FULL limit and merged oldest-pause-first, + // then capped — filling the window with approvals before ever querying + // human turns would starve exactly the entries a member's own inbox + // filter needs to see. + var pending = new ArrayList<>( + conversationStore.findByState(GroupConversationState.AWAITING_APPROVAL, groupId, clamped)); + pending.addAll(conversationStore.findByState(GroupConversationState.AWAITING_HUMAN_INPUT, groupId, clamped)); + pending.sort(Comparator.comparing(GroupConversation::getPausedAt, + Comparator.nullsLast(Comparator.naturalOrder()))); + return pending.stream() + .limit(clamped) .map(gc -> { var summary = new PendingApprovalSummary( gc.getId(), null, gc.getUserId(), gc.getPausedAt(), @@ -500,6 +561,12 @@ public List listGroupPendingApprovals(String groupId, in gc.getHitlTimeoutPolicy() != null ? gc.getHitlTimeoutPolicy().name() : null); summary.setGroupId(gc.getGroupId()); summary.setApprovalTimeout(gc.getHitlApprovalTimeout()); + if (gc.getHitlPauseType() != null) { + summary.setPauseType(gc.getHitlPauseType().name()); + } + if (gc.getPendingHumanInput() != null) { + summary.setPendingMemberId(gc.getPendingHumanInput().memberId()); + } return summary; }) .toList(); diff --git a/src/main/java/ai/labs/eddi/engine/internal/groups/MemberTurnExecutor.java b/src/main/java/ai/labs/eddi/engine/internal/groups/MemberTurnExecutor.java index aba17f729a..1ea7f11c16 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/groups/MemberTurnExecutor.java +++ b/src/main/java/ai/labs/eddi/engine/internal/groups/MemberTurnExecutor.java @@ -152,6 +152,72 @@ public TranscriptEntry executeAgentTurn(GroupMember member, GroupConversation gc DiscussionPhase phase, String targetAgentId, GroupDiscussionEventListener listener, GroupConversationService.MemberTurnCancellation cancellation, String conversationKey) throws GroupDiscussionException { + try { + return doExecuteAgentTurn(member, gc, input, protocol, phaseIdx, phase, targetAgentId, listener, cancellation, conversationKey); + } finally { + // I17: announce artifact writes this turn made. In a finally because an + // accepted write already happened whatever the turn's own outcome — + // a timeout or cancellation after the write must not swallow the event. + // Drained even with a null listener so the queue never grows unbounded. + announceArtifactChanges(gc, listener); + } + } + + /** + * I17: fires {@code artifact_updated} for every write queued during the turn. + * Public (not just the per-turn finally) because the discussion loop calls it + * once more when the leg ends, so a write accepted by a timed-out member's + * still-running agent — whose own turn already drained — is announced rather + * than stranded in the queue. + *

    + * The mutex guards the HANDOFF, never the callbacks. PARALLEL turns + * ending together must not split the queue between their drains and publish + * v2's event before v1's — but holding the monitor across + * {@code listener.onArtifactUpdated} would let one slow, backpressured SSE + * client block every other turn's end-of-turn drain. So exactly one thread at a + * time is the publisher: it drains under the mutex, releases it, fires + * the callbacks, and loops for anything that arrived meanwhile; every other + * thread sees the publisher flag and leaves, its changes guaranteed to ride the + * publisher's next loop. Write order is preserved (single announcer, FIFO + * queue) and no caller ever blocks on a listener. + */ + public static void announceArtifactChanges(GroupConversation gc, GroupDiscussionEventListener listener) { + while (true) { + List changes; + synchronized (gc.artifactAnnounceMutex()) { + if (gc.artifactAnnouncePublishing()) { + // The active publisher's next loop iteration drains our + // changes — leaving keeps this thread off the slow path. + return; + } + changes = gc.drainArtifactChanges(); + if (changes.isEmpty()) { + return; + } + gc.artifactAnnouncePublishing(true); + } + try { + if (listener != null) { + for (GroupConversation.ArtifactChange change : changes) { + listener.onArtifactUpdated(new GroupConversationEventSink.ArtifactUpdatedEvent( + change.artifactId(), change.name(), change.type(), change.version(), + change.editorAgentId(), change.status(), change.created())); + } + } + } finally { + synchronized (gc.artifactAnnounceMutex()) { + gc.artifactAnnouncePublishing(false); + } + } + // Loop: drain-and-publish anything queued while the callbacks ran — + // a thread that handed off above relies on exactly this pass. + } + } + + private TranscriptEntry doExecuteAgentTurn(GroupMember member, GroupConversation gc, String input, ProtocolConfig protocol, int phaseIdx, + DiscussionPhase phase, String targetAgentId, GroupDiscussionEventListener listener, + GroupConversationService.MemberTurnCancellation cancellation, String conversationKey) + throws GroupDiscussionException { if (cancellation != null && cancellation.isCancelled()) { throw new GroupConversationService.MemberTurnCancelledException(); @@ -164,6 +230,18 @@ public TranscriptEntry executeAgentTurn(GroupMember member, GroupConversation gc return executeGroupMemberTurn(member, gc, input, protocol, phaseIdx, phase, entryType, targetAgentId); } + // --- HUMAN member (I6): defense in depth, never the main path --- + // The phase loops intercept HUMAN speakers BEFORE this method and pause + // the discussion. Reaching here means an automated sub-round (convergence + // judge, dissent round, vote tiebreak, task-force wave, nested group) + // asked a human for an automated turn — those contexts cannot pause, so + // the turn is skipped, mirroring handleMemberPause's SKIP precedent. + if (member.memberType() == AgentGroupConfiguration.MemberType.HUMAN) { + return new TranscriptEntry(member.agentId(), member.displayName(), null, phaseIdx, phase.name(), + TranscriptEntryType.SKIPPED, Instant.now(), + "Human member does not take automated turns in this context — skipped", targetAgentId); + } + // Check agent availability try { var agent = agentFactory.getLatestReadyAgent(DEFAULT_ENV, member.agentId()); @@ -644,9 +722,14 @@ public TranscriptEntry executeGroupMemberTurn(GroupMember member, GroupConversat // supported in v1; cancel the stranded sub-pause (releases its timeout // schedule and removes it from pending-approval listings) and return a // SKIPPED entry with explanation. - if (subConversation.getState() == GroupConversationState.AWAITING_APPROVAL) { - LOGGER.warnf("Sub-group '%s' is awaiting approval — nested HITL not supported in v1; cancelling sub-pause", - subGroupId); + // I6: AWAITING_HUMAN_INPUT is the same stranded-nested-pause problem — + // save-time validation rejects HUMAN members in nested groups, but the + // child config can gain a human AFTER the parent saved; this is the + // runtime backstop. + if (subConversation.getState() == GroupConversationState.AWAITING_APPROVAL + || subConversation.getState() == GroupConversationState.AWAITING_HUMAN_INPUT) { + LOGGER.warnf("Sub-group '%s' paused (%s) — nested pauses are not supported in v1; cancelling sub-pause", + subGroupId, subConversation.getState()); try { groupConversationService.cancelDiscussion(subConversation.getId(), ControlSignal.CANCEL_GRACEFUL); } catch (Exception cancelEx) { @@ -656,7 +739,10 @@ public TranscriptEntry executeGroupMemberTurn(GroupMember member, GroupConversat } return new TranscriptEntry(member.agentId(), member.displayName(), null, phaseIdx, phase.name(), TranscriptEntryType.SKIPPED, Instant.now(), - "Sub-group awaiting approval — nested HITL not supported in v1", targetAgentId); + subConversation.getState() == GroupConversationState.AWAITING_HUMAN_INPUT + ? "Sub-group waiting on a human member — nested human turns are not supported in v1" + : "Sub-group awaiting approval — nested HITL not supported in v1", + targetAgentId); } // Extract the synthesized answer, or concatenate all responses diff --git a/src/main/java/ai/labs/eddi/engine/internal/groups/NegotiationEngine.java b/src/main/java/ai/labs/eddi/engine/internal/groups/NegotiationEngine.java new file mode 100644 index 0000000000..4919838f80 --- /dev/null +++ b/src/main/java/ai/labs/eddi/engine/internal/groups/NegotiationEngine.java @@ -0,0 +1,416 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.engine.internal.groups; + +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.DiscussionPhase; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.GroupMember; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.MemberType; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.PhaseType; +import ai.labs.eddi.configs.groups.model.GroupConversation; +import ai.labs.eddi.configs.groups.model.GroupConversation.Concession; +import ai.labs.eddi.configs.groups.model.GroupConversation.DecisionRecord; +import ai.labs.eddi.configs.groups.model.GroupConversation.DecisionType; +import ai.labs.eddi.configs.groups.model.GroupConversation.NegotiationState; +import ai.labs.eddi.configs.groups.model.GroupConversation.Proposal; +import ai.labs.eddi.configs.groups.model.GroupConversation.TranscriptEntry; +import ai.labs.eddi.configs.groups.model.GroupConversation.TranscriptEntryType; +import ai.labs.eddi.utils.LogSanitizer; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import org.jboss.logging.Logger; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * The negotiation protocol's state machine (I11): parses PROPOSAL and BARGAIN + * turns into {@link NegotiationState} mutations, detects unanimous agreement, + * and renders the ledger every bargaining turn is held accountable to. + *

    + * EDDI's other decision forms are win/lose; this is the trade form. The + * typed structure — every acceptance names a proposal id, every concession + * names what was received in return, and the ledger is quoted back into every + * turn — is precisely what stops sycophantic instant-agreement: an agent cannot + * "agree" without signing a specific set of terms on the record. + *

    + * Parse discipline mirrors {@link VoteTallyEngine}: three tiers (strict JSON → + * JSON embedded in prose/fence → give up), {@code FAIL_ON_TRAILING_TOKENS}, and + * an unreadable turn is prose with NO state effect (WARN) — never a guessed + * acceptance. + * + * @author ginccc + */ +public final class NegotiationEngine { + + private static final Logger LOGGER = Logger.getLogger(NegotiationEngine.class); + + /** The mechanism tag on an AGREEMENT reached by unanimous acceptance. */ + public static final String METHOD_NEGOTIATION = "negotiation"; + /** + * The mechanism tag on a VERDICT the moderator arbitrated after failed + * bargaining. + */ + public static final String METHOD_ARBITRATION = "arbitration"; + + /** + * Terms are quoted into every turn's ledger — bound the quoting, not the entry. + */ + static final int MAX_QUOTED_TERMS_CHARS = 600; + + private static final ObjectMapper MAPPER = JsonMapper.builder() + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .build(); + + private NegotiationEngine() { + } + + /** + * One parsed BARGAIN turn. Any of the parts may be absent; a turn that carries + * none of them is a non-move. + */ + record BargainMove(String accept, String proposalTerms, List concessions) { + } + + record ParsedConcession(String gaveUp, String inReturnFor) { + } + + /** + * Applies one completed PROPOSAL/BARGAIN repeat to the negotiation state. + *

    + * PROPOSAL entries: the whole turn is the proposal's terms (String v1). BARGAIN + * entries: the JSON contract — {@code accept} joins an OPEN proposal's + * signatories (and abandons the signer's own open offer), {@code proposal} + * supersedes the mover's own open proposals, withdraws their signatures from + * everyone else's, and puts new terms on the table; {@code concessions} append + * to the ledger. A turn carrying BOTH {@code accept} and {@code proposal} is + * resolved for the proposal — the accept is ignored with a WARN, because new + * terms mean the mover is not settling on existing ones. An unparseable BARGAIN + * turn is prose with no state effect. + * + * @param transcriptOffset + * absolute transcript index of the repeat's first entry — what turns + * a position in {@code repeatEntries} into the signed entry index an + * acceptance records + */ + public static void applyRepeat(GroupConversation gc, List repeatEntries, + int transcriptOffset, int round) { + if (repeatEntries == null || repeatEntries.isEmpty()) { + return; + } + NegotiationState state = gc.negotiationState(); + for (int i = 0; i < repeatEntries.size(); i++) { + TranscriptEntry entry = repeatEntries.get(i); + if (entry == null || entry.content() == null || entry.content().isBlank()) { + continue; + } + int entryIndex = transcriptOffset + i; + if (entry.type() == TranscriptEntryType.PROPOSAL) { + addProposal(state, entry.speakerAgentId(), entry.content().trim(), round, entryIndex); + } else if (entry.type() == TranscriptEntryType.BARGAIN) { + BargainMove move = parseBargain(entry.content()); + if (move == null) { + LOGGER.warnf("Group %s: unparseable BARGAIN turn from '%s' — prose only, no state effect", + LogSanitizer.sanitize(gc.getId()), LogSanitizer.sanitize(entry.speakerAgentId())); + continue; + } + applyMove(gc, state, entry.speakerAgentId(), move, round, entryIndex); + } + } + } + + /** + * Three-tier BARGAIN parse. {@code null} = not a readable move. + */ + static BargainMove parseBargain(String content) { + JsonNode node = readJson(content); + if (node == null) { + node = readJson(embeddedJson(content)); + } + if (node == null || !node.isObject()) { + return null; + } + String accept = node.path("accept").isTextual() && !node.path("accept").asText().isBlank() + ? node.path("accept").asText().trim() + : null; + String proposalTerms = null; + JsonNode proposal = node.path("proposal"); + if (proposal.isObject() && proposal.path("terms").isTextual() && !proposal.path("terms").asText().isBlank()) { + proposalTerms = proposal.path("terms").asText().trim(); + } + List concessions = new ArrayList<>(); + if (node.path("concessions").isArray()) { + for (JsonNode c : node.path("concessions")) { + String gaveUp = c.path("gaveUp").isTextual() ? c.path("gaveUp").asText().trim() : null; + String inReturnFor = c.path("inReturnFor").isTextual() ? c.path("inReturnFor").asText().trim() : null; + // The rule IS the structure: a concession that does not name what + // was received in return is not recorded. + if (gaveUp != null && !gaveUp.isBlank() && inReturnFor != null && !inReturnFor.isBlank()) { + concessions.add(new ParsedConcession(gaveUp, inReturnFor)); + } + } + } + if (accept == null && proposalTerms == null && concessions.isEmpty()) { + return null; + } + return new BargainMove(accept, proposalTerms, concessions); + } + + private static void applyMove(GroupConversation gc, NegotiationState state, String agentId, BargainMove move, + int round, int entryIndex) { + boolean counterProposal = move.proposalTerms() != null; + if (move.accept() != null && counterProposal) { + // Contradictory intent, resolved deterministically: new terms mean the + // mover is NOT settling on existing ones. Honoring the accept would + // record a signature the same turn walks away from. + LOGGER.warnf("Group %s: '%s' sent accept AND a counter-proposal in one turn — the counter-proposal wins, " + + "the accept is ignored", LogSanitizer.sanitize(gc.getId()), LogSanitizer.sanitize(agentId)); + } else if (move.accept() != null) { + Proposal target = findById(state, move.accept()); + if (target == null || !GroupConversation.PROPOSAL_OPEN.equals(target.status())) { + LOGGER.warnf("Group %s: '%s' accepted %s proposal '%s' — ignored", + LogSanitizer.sanitize(gc.getId()), LogSanitizer.sanitize(agentId), + target == null ? "unknown" : "non-open", LogSanitizer.sanitize(move.accept())); + } else if (!target.acceptedBy().contains(agentId)) { + List acceptedBy = new ArrayList<>(target.acceptedBy()); + acceptedBy.add(agentId); + Map indices = new LinkedHashMap<>(target.acceptanceEntryIndices()); + indices.put(agentId, entryIndex); + state.replaceProposal(target, new Proposal(target.id(), target.byAgentId(), target.round(), + target.terms(), target.status(), List.copyOf(acceptedBy), Map.copyOf(indices))); + // Signing someone else's terms abandons the signer's own live + // offer — the symmetric half of addProposal's withdrawal rule: any + // move that puts an agent's name on new terms removes their + // competing one from the table. + for (Proposal own : state.getProposals()) { + if (GroupConversation.PROPOSAL_OPEN.equals(own.status()) + && own.byAgentId().equals(agentId) && !own.id().equals(target.id())) { + state.replaceProposal(own, new Proposal(own.id(), own.byAgentId(), own.round(), own.terms(), + GroupConversation.PROPOSAL_SUPERSEDED, own.acceptedBy(), own.acceptanceEntryIndices())); + } + } + } + } + Proposal added = null; + if (counterProposal) { + added = addProposal(state, agentId, move.proposalTerms(), round, entryIndex); + } + for (ParsedConcession c : move.concessions()) { + state.addConcession(new Concession(agentId, round, c.gaveUp(), c.inReturnFor(), + added != null ? added.id() : null)); + } + } + + /** + * Puts new terms on the table. Because a new offer means the mover has moved, + * it does two withdrawals first: the proposer's own OPEN proposals are + * SUPERSEDED (one live offer per agent), and the proposer's signatures on + * every other open proposal are withdrawn — otherwise a proposal could + * reach "unanimity" on a signature its signatory abandoned turns ago, which is + * exactly the stale-agreement failure the typed ledger exists to prevent. The + * proposer then signs their own terms implicitly (the authoring entry is the + * signature). + */ + private static Proposal addProposal(NegotiationState state, String agentId, String terms, int round, int entryIndex) { + for (Proposal p : state.getProposals()) { + if (!GroupConversation.PROPOSAL_OPEN.equals(p.status())) { + continue; + } + if (p.byAgentId().equals(agentId)) { + state.replaceProposal(p, new Proposal(p.id(), p.byAgentId(), p.round(), p.terms(), + GroupConversation.PROPOSAL_SUPERSEDED, p.acceptedBy(), p.acceptanceEntryIndices())); + } else if (p.acceptedBy().contains(agentId)) { + List acceptedBy = new ArrayList<>(p.acceptedBy()); + acceptedBy.remove(agentId); + Map indices = new LinkedHashMap<>(p.acceptanceEntryIndices()); + indices.remove(agentId); + state.replaceProposal(p, new Proposal(p.id(), p.byAgentId(), p.round(), p.terms(), + p.status(), List.copyOf(acceptedBy), Map.copyOf(indices))); + } + } + Proposal proposal = new Proposal("p" + (state.getProposals().size() + 1), agentId, round, terms, + GroupConversation.PROPOSAL_OPEN, List.of(agentId), Map.of(agentId, entryIndex)); + state.addProposal(proposal); + return proposal; + } + + private static Proposal findById(NegotiationState state, String id) { + return state.getProposals().stream().filter(p -> p.id().equals(id)).findFirst().orElse(null); + } + + /** + * Unanimous agreement: every non-moderator AGENT participant has signed the + * SAME open proposal. Records the AGREEMENT decision — the signed acceptance + * entries ARE the co-signatures; their transcript indices ride + * {@code tally.signedAcceptances}, no new crypto — and returns {@code true} so + * the caller can end the phase's repeats early (the I2 plumbing). + */ + public static boolean checkAndRecordAgreement(GroupConversation gc, List participants, + String moderatorAgentId, String phaseName) { + NegotiationState state = gc.getNegotiation(); + if (state == null || state.getProposals().isEmpty() || participants == null || participants.isEmpty()) { + return false; + } + List required = participants.stream() + .filter(Objects::nonNull) + .filter(m -> m.memberType() != MemberType.GROUP) + .map(GroupMember::agentId) + .filter(Objects::nonNull) + .filter(id -> !id.equals(moderatorAgentId)) + .distinct() + .toList(); + if (required.isEmpty()) { + return false; + } + for (Proposal p : state.getProposals()) { + if (!GroupConversation.PROPOSAL_OPEN.equals(p.status())) { + continue; + } + if (p.acceptedBy().containsAll(required)) { + Map tally = new LinkedHashMap<>(); + tally.put("proposalId", p.id()); + tally.put("proposedBy", p.byAgentId()); + tally.put("terms", p.terms()); + tally.put("signedAcceptances", p.acceptanceEntryIndices()); + tally.put("concessions", state.getConcessions().stream().map(c -> { + Map line = new LinkedHashMap<>(); + line.put("by", c.byAgentId()); + line.put("gaveUp", c.gaveUp()); + line.put("inReturnFor", c.inReturnFor()); + return line; + }).toList()); + String outcome = "Agreement on proposal %s (by %s), signed by all %d participants: %s%s".formatted( + p.id(), p.byAgentId(), required.size(), truncate(p.terms()), + state.getConcessions().isEmpty() + ? "" + : " — " + state.getConcessions().size() + " concession(s) on the ledger."); + gc.setDecision(new DecisionRecord(DecisionType.AGREEMENT, outcome, p.id(), tally, List.of(), + METHOD_NEGOTIATION, phaseName, null)); + LOGGER.infof("Group %s: negotiation reached agreement on proposal %s", + LogSanitizer.sanitize(gc.getId()), p.id()); + return true; + } + } + return false; + } + + /** + * Records the moderator's arbitration as the discussion's VERDICT (I11) — the + * bargaining failed, so the last arbitration entry's prose is the decision. + * Never overwrites an existing decision. + */ + public static void recordArbitration(GroupConversation gc, List repeatEntries, String phaseName) { + if (gc.getDecision() != null || repeatEntries == null) { + return; + } + repeatEntries.stream() + .filter(e -> e != null && e.type() == TranscriptEntryType.SYNTHESIS && e.content() != null + && !e.content().isBlank()) + .reduce((first, second) -> second) + .ifPresent(e -> gc.setDecision(new DecisionRecord(DecisionType.VERDICT, e.content(), null, null, + List.of(), METHOD_ARBITRATION, phaseName, null))); + } + + /** + * Renders the negotiation state into a turn's input: open proposals (with ids, + * so an acceptance can name one) and the concession ledger. Appended to + * PROPOSAL/BARGAIN turns and to negotiation SYNTHESIS turns (arbitration and + * final synthesis quote the ledger); a no-op for every other phase or when + * there is no state yet. + */ + public static String appendStateIfRelevant(String input, GroupConversation gc, DiscussionPhase phase) { + NegotiationState state = gc.getNegotiation(); + if (state == null || (state.getProposals().isEmpty() && state.getConcessions().isEmpty())) { + return input; + } + if (phase.type() != PhaseType.PROPOSAL && phase.type() != PhaseType.BARGAIN + && phase.type() != PhaseType.SYNTHESIS) { + return input; + } + var sb = new StringBuilder(input); + sb.append("\n\nNEGOTIATION TABLE (the record — it will be quoted in the outcome):\n"); + sb.append("Open proposals:\n"); + boolean anyOpen = false; + for (Proposal p : state.getProposals()) { + if (GroupConversation.PROPOSAL_OPEN.equals(p.status())) { + anyOpen = true; + sb.append("- [").append(p.id()).append("] by ").append(p.byAgentId()) + .append(" (accepted by: ").append(String.join(", ", p.acceptedBy())).append("): ") + .append(truncate(p.terms())).append('\n'); + } + } + if (!anyOpen) { + sb.append("- (none)\n"); + } + sb.append("Concession ledger:\n"); + if (state.getConcessions().isEmpty()) { + sb.append("- (empty)\n"); + } else { + for (Concession c : state.getConcessions()) { + sb.append("- ").append(c.byAgentId()).append(" gave up \"").append(truncate(c.gaveUp())) + .append("\" in return for \"").append(truncate(c.inReturnFor())).append("\"\n"); + } + } + return sb.toString(); + } + + private static String truncate(String value) { + if (value == null) { + return ""; + } + return value.length() <= MAX_QUOTED_TERMS_CHARS ? value : value.substring(0, MAX_QUOTED_TERMS_CHARS) + "…"; + } + + private static JsonNode readJson(String content) { + if (content == null || content.isBlank()) { + return null; + } + try { + return MAPPER.readTree(content.trim()); + } catch (Exception e) { + return null; + } + } + + /** JSON embedded in prose or a code fence — first balanced object. */ + private static String embeddedJson(String content) { + if (content == null) { + return null; + } + int start = content.indexOf('{'); + if (start < 0) { + return null; + } + int depth = 0; + boolean inString = false; + boolean escaped = false; + for (int i = start; i < content.length(); i++) { + char c = content.charAt(i); + if (escaped) { + escaped = false; + continue; + } + if (c == '\\') { + escaped = true; + } else if (c == '"') { + inString = !inString; + } else if (!inString) { + if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + if (depth == 0) { + return content.substring(start, i + 1); + } + } + } + } + return null; + } +} diff --git a/src/main/java/ai/labs/eddi/engine/internal/groups/PhaseExecutionEngine.java b/src/main/java/ai/labs/eddi/engine/internal/groups/PhaseExecutionEngine.java index b974d2e71d..01a42a32fc 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/groups/PhaseExecutionEngine.java +++ b/src/main/java/ai/labs/eddi/engine/internal/groups/PhaseExecutionEngine.java @@ -25,6 +25,7 @@ import ai.labs.eddi.engine.lifecycle.GroupConversationEventSink; import ai.labs.eddi.engine.security.CallerIdentityContext; import ai.labs.eddi.engine.tenancy.QuotaExceededException; +import ai.labs.eddi.utils.LogSanitizer; import org.jboss.logging.Logger; import java.time.Instant; @@ -89,6 +90,60 @@ public PhaseExecutionEngine(MemberTurnExecutor memberTurnExecutor, GroupContextB this.callerIdentityContext = callerIdentityContext; } + /** + * Control-flow signal (I6): the phase reached a HUMAN member's turn and the + * discussion must pause for their input. Thrown by the sequential loop and the + * parallel human tail, caught ONLY by {@code executeDiscussion}'s phase + * dispatch, which commits the {@code AWAITING_HUMAN_INPUT} pause and returns. A + * RuntimeException on purpose — it must fly through method signatures that + * declare {@code GroupDiscussionException} WITHOUT being caught by the generic + * failure handling (the same reasoning as + * {@code GroupConversationService.MemberTurnCancelledException}). + */ + public static final class HumanTurnRequired extends RuntimeException { + private final transient GroupMember member; + private final int speakerIdx; + private final String renderedPrompt; + private final boolean parallel; + + /** + * @param member + * the HUMAN member whose turn is up + * @param speakerIdx + * the member's index — into the phase's resolved speaker list for + * sequential turns, into the phase's human-only sublist for parallel + * ones ({@code parallel} distinguishes the two) + * @param renderedPrompt + * the phase input rendered for this member, exactly as an agent + * would have received it + * @param parallel + * whether this turn belongs to a PARALLEL phase's human tail + */ + public HumanTurnRequired(GroupMember member, int speakerIdx, String renderedPrompt, boolean parallel) { + super("Human member turn required: " + member.agentId(), null, false, false); + this.member = member; + this.speakerIdx = speakerIdx; + this.renderedPrompt = renderedPrompt; + this.parallel = parallel; + } + + public GroupMember member() { + return member; + } + + public int speakerIdx() { + return speakerIdx; + } + + public String renderedPrompt() { + return renderedPrompt; + } + + public boolean parallel() { + return parallel; + } + } + public void executeSequentialPhase(GroupConversation gc, AgentGroupConfiguration config, List speakers, DiscussionPhase phase, ProtocolConfig protocol, String question, int phaseIdx, GroupDiscussionEventListener listener, AtomicInteger turnCounter, int maxTurns) @@ -403,6 +458,157 @@ public boolean recordDebateVerdict(GroupConversation gc, AgentGroupConfiguration return false; } + /** + * The conversation key the vote tiebreaker's private conversation lives under + * (I14). Not the moderator's agent id, for exactly + * {@link #JUDGE_CONVERSATION_KEY}'s reason: the tiebreak prompt must not become + * the moderator's recent history. + */ + static final String TIEBREAK_CONVERSATION_KEY = "__vote_tiebreak"; + + /** + * Tallies a completed VOTE phase into the discussion's {@link DecisionRecord} + * and fires {@code decision_reached} (I14). + *

    + * Quorum failures and ties go to the phase's {@code tiePolicy}: + * {@code MODERATOR_DECIDES} runs one moderator turn choosing among the + * unresolved options (method {@code vote+moderator-tiebreak}); + * {@code NO_DECISION} records the honest {@code NONE}. {@code + * HUMAN_DECIDES} is save-time rejected until I6 ships human members, so + * reaching it here means hand-edited storage — it degrades to NO_DECISION with + * a WARN rather than guessing. + *

    + * Like every decision producer, failure is prose-only, never a failed + * discussion. + */ + public void recordVoteDecision(GroupConversation gc, AgentGroupConfiguration config, DiscussionPhase phase, ProtocolConfig protocol, + int phaseIdx, List repeatEntries, List speakers, + GroupDiscussionEventListener listener, AtomicInteger turnCounter, int maxTurns) { + var voteConfig = phase.voteConfig() != null ? phase.voteConfig() : new AgentGroupConfiguration.VoteConfig(); + List transcriptSnapshot; + synchronized (gc.getTranscript()) { + transcriptSnapshot = List.copyOf(gc.getTranscript()); + } + List options = VoteTallyEngine.resolveOptions(voteConfig, transcriptSnapshot); + if (options.size() < 2) { + LOGGER.warnf("Group %s: VOTE phase '%s' resolved %d option(s) — a vote needs at least two; recording NONE", + LogSanitizer.sanitize(gc.getId()), LogSanitizer.sanitize(phase.name()), options.size()); + setDecisionCarryingDissents(gc, new DecisionRecord(DecisionType.NONE, + "The vote could not run: only " + options.size() + " option(s) were available.", + null, null, List.of(), VoteTallyEngine.METHOD_VOTE, phase.name(), null)); + fireDecisionReached(gc, listener); + return; + } + + List ballots = repeatEntries.stream().filter(e -> e != null && e.type() == TranscriptEntryType.VOTE).toList(); + int participants = speakers != null ? speakers.size() : ballots.size(); + var outcome = VoteTallyEngine.tally(ballots, participants, options, voteConfig, phase.name()); + + DecisionRecord decision = outcome.decision(); + if (!outcome.unresolvedOptions().isEmpty()) { + decision = switch (voteConfig.tiePolicy()) { + case MODERATOR_DECIDES -> { + // The tiebreak is a real LLM call — same two budgets as the + // convergence judge and the dissent round. Without this gate a + // tied vote issued one uncounted turn even after the discussion + // exhausted maxTurns or blew its cost ceiling. + if ((maxTurns > 0 && turnCounter != null && turnCounter.get() >= maxTurns) + || GroupCostLedger.wouldExceedCeiling(gc, protocol)) { + LOGGER.warnf("Group %s: VOTE phase '%s' tied but the turn/cost budget is exhausted — keeping NO_DECISION", + LogSanitizer.sanitize(gc.getId()), LogSanitizer.sanitize(phase.name())); + yield outcome.decision(); + } + if (turnCounter != null) { + turnCounter.incrementAndGet(); + } + yield moderatorTiebreak(gc, config, phase, protocol, phaseIdx, outcome, listener); + } + case HUMAN_DECIDES -> { + LOGGER.warnf("Group %s: VOTE phase '%s' has tiePolicy HUMAN_DECIDES, which needs human group members (I6) — " + + "recording NO_DECISION", LogSanitizer.sanitize(gc.getId()), LogSanitizer.sanitize(phase.name())); + yield outcome.decision(); + } + case NO_DECISION -> outcome.decision(); + }; + } + + setDecisionCarryingDissents(gc, decision); + LOGGER.infof("Group %s: VOTE phase '%s' concluded: %s", LogSanitizer.sanitize(gc.getId()), LogSanitizer.sanitize(phase.name()), + decision.type() == DecisionType.VOTE + ? LogSanitizer.sanitize(decision.outcome()) + : "no decision (" + LogSanitizer.sanitize(decision.outcome()) + ")"); + fireDecisionReached(gc, listener); + } + + /** + * One moderator turn choosing among the unresolved options. The reply is + * resolved by the same exact-scan rule as a tier-2 ballot; an unreadable reply + * keeps the tally's honest NONE. + */ + private DecisionRecord moderatorTiebreak(GroupConversation gc, AgentGroupConfiguration config, DiscussionPhase phase, + ProtocolConfig protocol, int phaseIdx, VoteTallyEngine.TallyOutcome outcome, + GroupDiscussionEventListener listener) { + String moderatorAgentId = config.getModeratorAgentId(); + if (moderatorAgentId == null || moderatorAgentId.isBlank()) { + LOGGER.warnf("Group %s: tiePolicy MODERATOR_DECIDES but the group names no moderatorAgentId — keeping NO_DECISION", + LogSanitizer.sanitize(gc.getId())); + return outcome.decision(); + } + String reason = outcome.quorumReached() ? "The vote tied between" : "The vote did not reach quorum; decide among"; + String input = """ + %s these options: + %s + + As the moderator, choose exactly ONE. Reply with ONLY the exact text of the option you choose. + """.formatted(reason, outcome.unresolvedOptions().stream().map(o -> "- " + o).collect(Collectors.joining("\n"))); + try { + var moderator = new GroupMember(moderatorAgentId, "Vote Tiebreaker", 0, "MODERATOR"); + TranscriptEntry reply = memberTurnExecutor.executeAgentTurn(moderator, gc, input, protocol, phaseIdx, phase, null, listener, + null, TIEBREAK_CONVERSATION_KEY); + String choice = VoteTallyEngine.resolveChoice(reply != null ? reply.content() : null, outcome.unresolvedOptions()); + if (choice == null) { + LOGGER.warnf("Group %s: the tiebreaker's reply named no single option — keeping NO_DECISION", + LogSanitizer.sanitize(gc.getId())); + return outcome.decision(); + } + DecisionRecord base = outcome.decision(); + // Dissents against the CHOSEN option, from the carried ballots — the + // unresolved record's own list is necessarily empty, and reusing it + // dropped the minority report for exactly the closest votes. + return new DecisionRecord(DecisionType.VOTE, + "\"%s\" chosen by the moderator (%s).".formatted(choice, + outcome.quorumReached() ? "tie break" : "quorum failure"), + choice, base.tally(), VoteTallyEngine.losingDissents(outcome.ballots(), choice), + VoteTallyEngine.METHOD_TIEBREAK, phase.name(), reply.content()); + } catch (Exception e) { + LOGGER.warnf("Group %s: vote tiebreak failed (%s) — keeping NO_DECISION", + LogSanitizer.sanitize(gc.getId()), LogSanitizer.sanitize(e.getMessage())); + return outcome.decision(); + } + } + + /** Immutable-record merge: a new decision keeps dissents already collected. */ + private static void setDecisionCarryingDissents(GroupConversation gc, DecisionRecord decision) { + DecisionRecord existing = gc.getDecision(); + if (existing != null && existing.dissents() != null && !existing.dissents().isEmpty() + && (decision.dissents() == null || decision.dissents().isEmpty())) { + decision = new DecisionRecord(decision.type(), decision.outcome(), decision.winner(), decision.tally(), + existing.dissents(), decision.method(), decision.decidedAtPhase(), decision.raw()); + } + gc.setDecision(decision); + } + + /** + * The {@code decision_reached} producer (the §4 gap, folded in with I14): fired + * whenever a {@link DecisionRecord} lands on the discussion. The Slack listener + * skips {@code NONE} records itself; SSE forwards everything. + */ + public void fireDecisionReached(GroupConversation gc, GroupDiscussionEventListener listener) { + if (listener != null && gc.getDecision() != null) { + listener.onDecisionReached(new GroupConversationEventSink.DecisionReachedEvent(gc.getDecision())); + } + } + /** * Merges dissents into the discussion's {@code DecisionRecord}, creating a * {@code NONE}-type record when no decision-producing feature ran. Records are @@ -496,7 +702,8 @@ public void executeSequentialPhase(GroupConversation gc, AgentGroupConfiguration AtomicInteger turnCounter, int maxTurns, int startSpeakerIdx) throws GroupDiscussionException { int from = Math.min(Math.max(startSpeakerIdx, 0), speakers.size()); - for (GroupMember speaker : speakers.subList(from, speakers.size())) { + for (int idx = from; idx < speakers.size(); idx++) { + GroupMember speaker = speakers.get(idx); if (turnCounter.get() >= maxTurns) { break; } @@ -505,13 +712,28 @@ public void executeSequentialPhase(GroupConversation gc, AgentGroupConfiguration if (GroupCostLedger.enforceCeiling(gc, protocol, phaseIdx, phase)) { break; } + // I6: a human's turn pauses the discussion. AFTER the budget checks — + // an exhausted budget means no more turns, the human's included — and + // BEFORE the turn count (the pause commit accounts for the turn, so a + // restored pause does not double-count it). The prompt is rendered + // HERE, against exactly the transcript an agent speaker would see. + if (speaker.memberType() == AgentGroupConfiguration.MemberType.HUMAN) { + String humanInput = contextBuilder.buildPhaseInput(phase, speaker, question, gc.getTranscript(), phaseIdx, null, + GroupConversationService.rosterWithRecruits(config, gc), config.getContextWindow(), gc, + config.getRetroConfig()); + throw new HumanTurnRequired(speaker, idx, humanInput, false); + } turnCounter.incrementAndGet(); if (listener != null) { listener.onSpeakerStart( new GroupConversationEventSink.SpeakerStartEvent(speaker.agentId(), speaker.displayName(), phaseIdx, phase.name())); } String input = contextBuilder.buildPhaseInput(phase, speaker, question, gc.getTranscript(), phaseIdx, null, - GroupConversationService.rosterWithRecruits(config, gc)); + GroupConversationService.rosterWithRecruits(config, gc), config.getContextWindow(), gc, + config.getRetroConfig()); + // I11: the negotiation table lives on gc, which buildPhaseInput does + // not see — appended here (no-op for non-negotiation phases). + input = NegotiationEngine.appendStateIfRelevant(input, gc, phase); TranscriptEntry entry = memberTurnExecutor.executeAgentTurn(speaker, gc, input, protocol, phaseIdx, phase, null, listener); gc.getTranscript().add(entry); if (listener != null) { @@ -525,6 +747,48 @@ public void executeParallelPhase(GroupConversation gc, AgentGroupConfiguration c ProtocolConfig protocol, String question, int phaseIdx, GroupDiscussionEventListener listener, AtomicInteger turnCounter, int maxTurns) throws GroupDiscussionException { + executeParallelPhase(gc, config, speakers, phase, protocol, question, phaseIdx, listener, turnCounter, maxTurns, null); + } + + /** + * @param humanResumeIdx + * {@code null} for every fresh run. Non-null only when resuming an + * I6 {@code HUMAN_TURN_PARALLEL} pause: the agent fan-out already + * ran before the pause, so the resumed leg skips straight to the + * phase's human tail, starting at this index into the human-only + * sublist (see {@code GroupConversation.ResumePoint}). + */ + public void executeParallelPhase(GroupConversation gc, AgentGroupConfiguration config, List speakers, DiscussionPhase phase, + ProtocolConfig protocol, String question, int phaseIdx, GroupDiscussionEventListener listener, + AtomicInteger turnCounter, int maxTurns, Integer humanResumeIdx) + throws GroupDiscussionException { + + // I6: humans never join the fan-out — an LLM answers in seconds, a human + // in minutes-to-days, and a paused future would pin the whole batch. + // Agents run first (concurrently), then humans are prompted sequentially, + // each pausing the discussion in turn. + List humans = speakers.stream() + .filter(s -> s.memberType() == AgentGroupConfiguration.MemberType.HUMAN).toList(); + List agentSpeakers = humans.isEmpty() + ? speakers + : speakers.stream().filter(s -> s.memberType() != AgentGroupConfiguration.MemberType.HUMAN).toList(); + + if (humanResumeIdx != null) { + // Resumed leg: the fan-out already ran before the pause. The remaining + // humans' prompts must stay blind to their peers — the fresh leg uses + // the pre-fan-out snapshot for that, which no longer exists here, so + // the resumed bound is "no entries of this phase at all" (deliberately + // a touch stronger for repeats > 1 than the fresh-leg bound; a blind + // round that stays blind is the honest failure direction). + List blindTranscript; + synchronized (gc.getTranscript()) { + blindTranscript = gc.getTranscript().stream() + .filter(e -> e == null || e.phaseIndex() != phaseIdx).toList(); + } + promptHumanTail(gc, config, humans, phase, protocol, question, phaseIdx, turnCounter, maxTurns, + humanResumeIdx, blindTranscript); + return; + } // I1: whole-batch check — a PARALLEL phase fans every speaker out at once, // so there is no per-speaker checkpoint to gate individually; this is the @@ -535,13 +799,13 @@ public void executeParallelPhase(GroupConversation gc, AgentGroupConfiguration c } // Cap batch size to remaining turn budget - int remainingTurns = maxTurns > 0 ? Math.max(0, maxTurns - turnCounter.get()) : speakers.size(); + int remainingTurns = maxTurns > 0 ? Math.max(0, maxTurns - turnCounter.get()) : agentSpeakers.size(); if (remainingTurns == 0) { return; } List batchSpeakers = maxTurns > 0 - ? speakers.subList(0, Math.min(speakers.size(), remainingTurns)) - : speakers; + ? agentSpeakers.subList(0, Math.min(agentSpeakers.size(), remainingTurns)) + : agentSpeakers; // SAFETY: Snapshot the transcript so parallel tasks each see a consistent view. // Iterating a Collections.synchronizedList requires holding its monitor. @@ -586,7 +850,10 @@ public void executeParallelPhase(GroupConversation gc, AgentGroupConfiguration c .map(speaker -> CompletableFuture.supplyAsync(callerIdentityContext.withIdentitySupplying(phaseCaller, () -> { try { String input = contextBuilder.buildPhaseInput(phase, speaker, question, snapshotTranscript, phaseIdx, null, - GroupConversationService.rosterWithRecruits(config, gc)); + GroupConversationService.rosterWithRecruits(config, gc), config.getContextWindow(), gc, + config.getRetroConfig()); + // I11: see the sequential loop — appended, not templated. + input = NegotiationEngine.appendStateIfRelevant(input, gc, phase); return memberTurnExecutor.executeAgentTurn(speaker, gc, input, protocol, phaseIdx, phase, null, listener, cancellation); } catch (MemberTurnCancelledException e) { // The orchestrator stopped waiting for this batch — surface the @@ -678,6 +945,35 @@ public void executeParallelPhase(GroupConversation gc, AgentGroupConfiguration c } // Count all completed turns for this batch (parallel turns are atomic batches) turnCounter.addAndGet(batchSpeakers.size()); + + // I6: the human tail, prompted against the PRE-fan-out snapshot so a + // "parallel" (independent) round stays independent — a human answering + // after the agents must not read their answers first. + promptHumanTail(gc, config, humans, phase, protocol, question, phaseIdx, turnCounter, maxTurns, 0, snapshotTranscript); + } + + /** + * Prompts a PARALLEL phase's HUMAN members one at a time, from + * {@code startIdx}. Throws {@link HumanTurnRequired} for the first human whose + * turn is still owed — one pause per human, sequentially. Budget checks mirror + * the sequential loop: an exhausted budget owes no more turns, human or not. + */ + private void promptHumanTail(GroupConversation gc, AgentGroupConfiguration config, List humans, DiscussionPhase phase, + ProtocolConfig protocol, String question, int phaseIdx, AtomicInteger turnCounter, int maxTurns, + int startIdx, List promptTranscript) { + for (int i = Math.max(0, startIdx); i < humans.size(); i++) { + if (maxTurns > 0 && turnCounter.get() >= maxTurns) { + return; + } + if (GroupCostLedger.enforceCeiling(gc, protocol, phaseIdx, phase)) { + return; + } + GroupMember human = humans.get(i); + String input = contextBuilder.buildPhaseInput(phase, human, question, promptTranscript, phaseIdx, null, + GroupConversationService.rosterWithRecruits(config, gc), config.getContextWindow(), gc, + config.getRetroConfig()); + throw new HumanTurnRequired(human, i, input, true); + } } /** @@ -715,7 +1011,8 @@ public void executePeerTargetedPhase(GroupConversation gc, AgentGroupConfigurati new GroupConversationEventSink.SpeakerStartEvent(speaker.agentId(), speaker.displayName(), phaseIdx, phase.name())); } String input = contextBuilder.buildPhaseInput(phase, speaker, question, gc.getTranscript(), phaseIdx, target, - GroupConversationService.rosterWithRecruits(config, gc)); + GroupConversationService.rosterWithRecruits(config, gc), config.getContextWindow(), gc, + config.getRetroConfig()); TranscriptEntry entry = memberTurnExecutor.executeAgentTurn(speaker, gc, input, protocol, phaseIdx, phase, target.agentId(), listener); gc.getTranscript().add(entry); diff --git a/src/main/java/ai/labs/eddi/engine/internal/groups/RetroEngine.java b/src/main/java/ai/labs/eddi/engine/internal/groups/RetroEngine.java new file mode 100644 index 0000000000..73d6051a93 --- /dev/null +++ b/src/main/java/ai/labs/eddi/engine/internal/groups/RetroEngine.java @@ -0,0 +1,226 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.engine.internal.groups; + +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.RetroConfig; +import ai.labs.eddi.configs.groups.model.GroupConversation; +import ai.labs.eddi.configs.groups.model.GroupConversation.TranscriptEntry; +import ai.labs.eddi.configs.groups.model.GroupConversation.TranscriptEntryType; +import ai.labs.eddi.configs.properties.IUserMemoryStore; +import ai.labs.eddi.configs.properties.model.Property.Visibility; +import ai.labs.eddi.configs.properties.model.UserMemoryEntry; +import ai.labs.eddi.engine.api.IGroupConversationService.GroupDiscussionEventListener; +import ai.labs.eddi.engine.lifecycle.GroupConversationEventSink; +import ai.labs.eddi.utils.LogSanitizer; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import org.jboss.logging.Logger; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; + +/** + * Turns a RETRO phase's contributions into team-owned group memory (I8). + *

    + * Every discussion otherwise evaporates: nothing a group learns about its own + * process survives the transcript. Parsed lessons are upserted into + * {@link IUserMemoryStore} under the synthetic team owner + * ({@code "group:"+groupId}, {@code group} visibility), which member + * conversations already load at init — so lessons surface as + * {@code {properties.*}} in every later discussion with no new namespace. + *

    + * Bounded growth is non-negotiable: per-turn lessons cap at + * {@code maxLessonsPerRun} (enforced here, whatever the model was told), the + * stored set FIFOs at {@code maxStoredLessons}, and the idempotency key + * {@code retro:} with a fixed source agent makes re-running the + * same retro a no-op rather than a duplicate. + *

    + * Every failure is warn-and-continue — a lesson that fails to store must never + * fail the discussion that produced it. + * + * @author ginccc + */ +public final class RetroEngine { + + private static final Logger LOGGER = Logger.getLogger(RetroEngine.class); + + /** + * Fixed {@code sourceAgentId} for every lesson. The upsert identity for + * group-visible entries is {@code (userId, key, sourceAgentId)} — a real + * speaker id there would make the same lesson from two speakers two rows, + * defeating the idempotency key. + */ + static final String RETRO_SOURCE = "retro"; + + /** Idempotency-key prefix: {@code retro:}. */ + static final String KEY_PREFIX = "retro:"; + + private static final ObjectMapper MAPPER = JsonMapper.builder() + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .build(); + + private RetroEngine() { + } + + /** One parsed lesson. */ + public record Lesson(String lesson, String context) { + } + + /** + * Three-tier parse of one retro contribution: strict JSON → JSON embedded in + * prose/fence → give up (empty). Truncated at {@code maxLessonsPerRun} + * regardless of what the model produced. + */ + static List parseLessons(String content, int maxLessonsPerRun) { + if (content == null || content.isBlank()) { + return List.of(); + } + JsonNode node = readJson(content); + if (node == null) { + node = readJson(embeddedJson(content)); + } + if (node == null || !node.path("lessons").isArray()) { + return List.of(); + } + List lessons = new ArrayList<>(); + for (JsonNode lessonNode : node.path("lessons")) { + String lesson = lessonNode.path("lesson").isTextual() ? lessonNode.path("lesson").asText().trim() : null; + if (lesson == null || lesson.isBlank()) { + continue; + } + String context = lessonNode.path("context").isTextual() ? lessonNode.path("context").asText().trim() : null; + lessons.add(new Lesson(lesson, context != null && !context.isBlank() ? context : null)); + if (lessons.size() >= maxLessonsPerRun) { + break; + } + } + return lessons; + } + + /** + * Harvests a completed RETRO phase: parses each RETRO entry, upserts the + * lessons team-owned, FIFO-evicts past the stored cap, and fires + * {@code retro_recorded}. + */ + public static void harvest(GroupConversation gc, RetroConfig retroConfig, List repeatEntries, + IUserMemoryStore userMemoryStore, String phaseName, GroupDiscussionEventListener listener) { + if (userMemoryStore == null) { + LOGGER.warnf("Group %s: RETRO phase '%s' ran but no user memory store is available — lessons are not persisted", + LogSanitizer.sanitize(gc.getId()), LogSanitizer.sanitize(phaseName)); + // The event contract holds even when nothing can be stored: a RETRO + // phase always emits retro_recorded (review finding — this path + // returned silently, leaving SSE consumers waiting). + if (listener != null) { + listener.onRetroRecorded(new GroupConversationEventSink.RetroRecordedEvent(gc.getGroupId(), phaseName, 0)); + } + return; + } + RetroConfig config = retroConfig != null ? retroConfig : new RetroConfig(); + String teamOwner = IUserMemoryStore.TEAM_OWNER_PREFIX + gc.getGroupId(); + + // maxLessonsPerRun bounds the whole HARVEST, not each transcript entry — + // a multi-participant or multi-repeat RETRO phase produces several RETRO + // entries, and a per-entry cap multiplied by entry count (review finding). + int remaining = config.maxLessonsPerRun(); + int stored = 0; + for (TranscriptEntry entry : repeatEntries) { + if (remaining <= 0) { + break; + } + if (entry == null || entry.type() != TranscriptEntryType.RETRO) { + continue; + } + for (Lesson lesson : parseLessons(entry.content(), remaining)) { + try { + String value = lesson.context() != null ? lesson.lesson() + " (applies: " + lesson.context() + ")" : lesson.lesson(); + userMemoryStore.upsert(new UserMemoryEntry(null, teamOwner, KEY_PREFIX + lessonHash(lesson.lesson()), value, + "context", Visibility.group, RETRO_SOURCE, List.of(gc.getGroupId()), gc.getId(), false, 0, + Instant.now(), Instant.now())); + stored++; + remaining--; + } catch (Exception e) { + LOGGER.warnf("Group %s: failed to store a retro lesson: %s", + LogSanitizer.sanitize(gc.getId()), LogSanitizer.sanitize(e.getMessage())); + } + } + } + if (stored > 0) { + evictPastCap(userMemoryStore, teamOwner, gc.getGroupId(), config.maxStoredLessons()); + LOGGER.infof("Group %s: RETRO phase '%s' stored %d lesson(s) for team %s", + LogSanitizer.sanitize(gc.getId()), LogSanitizer.sanitize(phaseName), stored, LogSanitizer.sanitize(teamOwner)); + } + if (listener != null) { + listener.onRetroRecorded(new GroupConversationEventSink.RetroRecordedEvent(gc.getGroupId(), phaseName, stored)); + } + } + + /** + * FIFO at the stored cap, ordered by CREATION time. The {@code most_recent} + * recall order sorts by {@code updatedAt}, and reharvesting an existing lesson + * refreshes that — so eviction keyed on recall order could evict a + * later-CREATED lesson while an old-but-reharvested one survived (review + * finding). {@code createdAt} is {@code setOnInsert} at the store, so it is the + * stable insertion stamp FIFO needs. Team-owned entries are untouchable by + * {@code UserMemoryTool}'s own eviction (which only ever removes the calling + * agent's {@code self} entries), so this is the ONLY reaper. + */ + private static void evictPastCap(IUserMemoryStore store, String teamOwner, String groupId, int maxStoredLessons) { + try { + List entries = store.getVisibleEntries(teamOwner, RETRO_SOURCE, List.of(groupId), "most_recent", -1) + .stream() + // Newest created first; entries without a createdAt (pre-stamp + // documents) count as oldest and are evicted first. + .sorted(Comparator.comparing(UserMemoryEntry::createdAt, + Comparator.nullsFirst(Comparator.naturalOrder())).reversed()) + .toList(); + for (int i = maxStoredLessons; i < entries.size(); i++) { + UserMemoryEntry oldest = entries.get(i); + if (oldest.id() != null) { + store.deleteEntry(oldest.id()); + } + } + } catch (Exception e) { + LOGGER.warnf("Failed to FIFO-evict retro lessons for %s: %s", + LogSanitizer.sanitize(teamOwner), LogSanitizer.sanitize(e.getMessage())); + } + } + + /** First 16 hex chars of SHA-256 — collision-safe enough for a 50-entry set. */ + static String lessonHash(String lesson) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(lesson.toLowerCase().strip().getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash, 0, 8); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is mandatory on every JVM; unreachable. + throw new IllegalStateException(e); + } + } + + private static JsonNode readJson(String text) { + if (text == null || text.isBlank()) { + return null; + } + try { + return MAPPER.readTree(text.trim()); + } catch (Exception e) { + return null; + } + } + + private static String embeddedJson(String content) { + int start = content.indexOf('{'); + int end = content.lastIndexOf('}'); + return start >= 0 && end > start ? content.substring(start, end + 1) : null; + } +} diff --git a/src/main/java/ai/labs/eddi/engine/internal/groups/TaskBidEngine.java b/src/main/java/ai/labs/eddi/engine/internal/groups/TaskBidEngine.java new file mode 100644 index 0000000000..4cc5965f71 --- /dev/null +++ b/src/main/java/ai/labs/eddi/engine/internal/groups/TaskBidEngine.java @@ -0,0 +1,226 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.engine.internal.groups; + +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.AssignmentMode; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.GroupMember; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.TaskDefinition; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration; +import ai.labs.eddi.configs.groups.model.SharedTaskList.AwardedBid; +import ai.labs.eddi.configs.groups.model.SharedTaskList.TaskItem; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import org.jboss.logging.Logger; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * CNP-lite bid parsing and awarding (I18). The planner cannot know members' + * actual fit or load; the Contract Net Protocol's announce-bid-award loop maps + * onto the existing wave scheduler: unassigned BID-mode tasks are announced to + * eligible members in blind, parallel bid turns, and each task is awarded to + * the highest self-assessed confidence. + *

    + * The award is deterministic and never stalls a wave: ties break by speaking + * order, and a task nobody bid on falls back to the ROLE assignment path. Parse + * discipline mirrors {@link VoteTallyEngine}: three tiers, {@code + * FAIL_ON_TRAILING_TOKENS}, and an unreadable reply simply casts no bids. + * + * @author ginccc + */ +public final class TaskBidEngine { + + private static final Logger LOGGER = Logger.getLogger(TaskBidEngine.class); + + /** + * The auction runs only when it can beat its own overhead — see + * {@link #auctionWorthwhile}. + */ + static final int MIN_BIDDERS = 2; + static final int MIN_TASKS = 2; + + private static final ObjectMapper MAPPER = JsonMapper.builder() + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .build(); + + private TaskBidEngine() { + } + + /** One member's bid on one announced task. */ + public record Bid(String agentId, Integer speakingOrder, String taskSubject, double confidence, + String estimatedComplexity, String rationale) { + } + + /** + * The effective assignment mode of a task (I18): the task's own mode wins, then + * the group's {@code taskListConfig.assignmentMode()}, then ROLE — every config + * that predates I18 resolves to ROLE. + */ + public static AssignmentMode effectiveMode(TaskDefinition td, AgentGroupConfiguration config) { + if (td != null && td.assignmentMode() != null) { + return td.assignmentMode(); + } + if (config != null && config.getTaskListConfig() != null && config.getTaskListConfig().assignmentMode() != null) { + return config.getTaskListConfig().assignmentMode(); + } + return AssignmentMode.ROLE; + } + + /** + * Whether announcing is worth its own overhead: with one bidder the winner is + * predetermined, and with one task a role/round-robin assignment costs zero LLM + * calls. The caller logs the skip — a silent cap reads as coverage. + */ + public static boolean auctionWorthwhile(int eligibleBidders, int unassignedTasks) { + return eligibleBidders >= MIN_BIDDERS && unassignedTasks >= MIN_TASKS; + } + + /** + * The blind bid prompt for one member: the announced tasks and the JSON + * contract — deliberately NO transcript and NO peer bids ({@code + * ContextScope.NONE} semantics; blindness is what makes the confidences + * comparable). + */ + public static String buildBidPrompt(List tasks, GroupMember member, String question) { + var sb = new StringBuilder(); + sb.append("A task force is working on:\n\"").append(question).append("\"\n\n"); + sb.append("The following tasks are open for assignment. As ").append(member.displayName()) + .append(", bid ONLY on tasks you are genuinely well-suited to execute.\n\n"); + for (TaskItem task : tasks) { + sb.append("- \"").append(task.subject()).append("\": ").append(task.description()).append('\n'); + } + sb.append( + """ + + Reply with ONLY this JSON: + {"bids": [{"subject": "", "confidence": 0.0-1.0, "estimatedComplexity": "XS|S|M|L", "rationale": ""}]} + + Bid only where you add real capability — an inflated confidence wins you work you will fail at, on the record."""); + return sb.toString(); + } + + /** + * Three-tier parse of one member's bid reply. Unknown subjects are dropped (an + * out-of-contract bid is not guessed onto a task), confidence is clamped to [0, + * 1], and an unreadable reply casts no bids — never a stalled wave. + */ + public static List parseBids(String content, GroupMember member, Set announcedSubjects) { + if (content == null || content.isBlank()) { + return List.of(); + } + JsonNode node = readJson(content); + if (node == null) { + node = readJson(embeddedJson(content)); + } + if (node == null || !node.path("bids").isArray()) { + return List.of(); + } + Map canonicalBySubject = announcedSubjects.stream() + .collect(Collectors.toMap(s -> s.toLowerCase(Locale.ROOT), s -> s, (a, b) -> a)); + List bids = new ArrayList<>(); + Set seen = new HashSet<>(); + for (JsonNode b : node.path("bids")) { + String subject = b.path("subject").isTextual() ? b.path("subject").asText().trim() : null; + if (subject == null || subject.isBlank()) { + continue; + } + String canonical = canonicalBySubject.get(subject.toLowerCase(Locale.ROOT)); + if (canonical == null) { + LOGGER.debugf("Bid from '%s' on unannounced task '%s' — dropped", member.agentId(), subject); + continue; + } + if (!seen.add(canonical)) { + continue; // first bid per task wins within one reply + } + double confidence = b.path("confidence").isNumber() + ? Math.max(0.0, Math.min(1.0, b.path("confidence").asDouble())) + : 0.0; + String complexity = b.path("estimatedComplexity").isTextual() ? b.path("estimatedComplexity").asText() : null; + String rationale = b.path("rationale").isTextual() ? b.path("rationale").asText() : null; + bids.add(new Bid(member.agentId(), member.speakingOrder(), canonical, confidence, complexity, rationale)); + } + return bids; + } + + /** + * Awards each announced task: highest confidence wins; a tie breaks + * deterministically by speaking order (unset orders last, then agent id so two + * unordered members still resolve identically on every pod). Tasks nobody bid + * on are absent from the result — the caller falls back to ROLE. + */ + public static Map award(List tasks, List allBids) { + Map> bidsByTask = allBids.stream().collect(Collectors.groupingBy(Bid::taskSubject)); + Map awards = new LinkedHashMap<>(); + for (TaskItem task : tasks) { + List bids = bidsByTask.getOrDefault(task.subject(), List.of()); + bids.stream() + .max(Comparator.comparingDouble(Bid::confidence) + .thenComparing(b -> b.speakingOrder() != null ? b.speakingOrder() : Integer.MAX_VALUE, + Comparator.reverseOrder()) + .thenComparing(Bid::agentId, Comparator.reverseOrder())) + .ifPresent(winner -> awards.put(task.id(), + new AwardedBid(winner.agentId(), winner.confidence(), winner.estimatedComplexity(), + winner.rationale()))); + } + return awards; + } + + private static JsonNode readJson(String content) { + if (content == null || content.isBlank()) { + return null; + } + try { + return MAPPER.readTree(content.trim()); + } catch (Exception e) { + return null; + } + } + + /** JSON embedded in prose or a code fence — first balanced object. */ + private static String embeddedJson(String content) { + if (content == null) { + return null; + } + int start = content.indexOf('{'); + if (start < 0) { + return null; + } + int depth = 0; + boolean inString = false; + boolean escaped = false; + for (int i = start; i < content.length(); i++) { + char c = content.charAt(i); + if (escaped) { + escaped = false; + continue; + } + if (c == '\\') { + escaped = true; + } else if (c == '"') { + inString = !inString; + } else if (!inString) { + if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + if (depth == 0) { + return content.substring(start, i + 1); + } + } + } + } + return null; + } +} diff --git a/src/main/java/ai/labs/eddi/engine/internal/groups/TaskForceEngine.java b/src/main/java/ai/labs/eddi/engine/internal/groups/TaskForceEngine.java index 67ddd31e68..a4506a8c7d 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/groups/TaskForceEngine.java +++ b/src/main/java/ai/labs/eddi/engine/internal/groups/TaskForceEngine.java @@ -22,6 +22,7 @@ import ai.labs.eddi.datastore.serialization.IJsonSerialization; import ai.labs.eddi.engine.api.IGroupConversationService.GroupDiscussionEventListener; import ai.labs.eddi.engine.api.IGroupConversationService.GroupDiscussionException; +import ai.labs.eddi.engine.internal.GroupConversationService; import ai.labs.eddi.engine.internal.GroupConversationService.MemberTurnCancellation; import ai.labs.eddi.engine.internal.GroupConversationService.MemberTurnCancelledException; import ai.labs.eddi.engine.internal.TaskListParser; @@ -182,6 +183,13 @@ private void executeTaskPlanPhase(GroupConversation gc, AgentGroupConfiguration for (int i = 0; i < createdItems.size(); i++) { TaskItem task = createdItems.get(i); TaskDefinition td = config.getTasks().get(i); + // I18: BID-mode tasks are deliberately left unassigned — the + // EXECUTE wave announces them and awards by bid; assigning here + // would preempt the auction with the planner's guess. + if (TaskBidEngine.effectiveMode(td, config) == AgentGroupConfiguration.AssignmentMode.BID) { + LOGGER.debugf("Task '%s' is BID-mode — left for the execution wave's auction", task.subject()); + continue; + } String assignedAgentId = resolveTaskAssignment( td.assignToRole(), config.getMembers(), config.getModeratorAgentId(), i); if (assignedAgentId != null) { @@ -253,6 +261,14 @@ private void executeTaskPlanPhase(GroupConversation gc, AgentGroupConfiguration TaskItem task = new TaskItem(pt.subject(), pt.description(), pt.priority()); gc.getTaskList().addTask(task); + // I18: under a BID-mode default, planned tasks stay unassigned for + // the execution wave's auction (the planner cannot know members' + // actual fit or load — that is the point of bidding). + if (TaskBidEngine.effectiveMode(null, config) == AgentGroupConfiguration.AssignmentMode.BID) { + LOGGER.debugf("Planned task '%s' is BID-mode — left for the execution wave's auction", pt.subject()); + continue; + } + // Resolve assignment — null-safe (C4 fix) String agentId = TaskListParser.resolveAgent(pt.assignedTo(), config.getMembers()); if (agentId == null) { @@ -332,6 +348,10 @@ public void executeTaskExecutionPhase(GroupConversation gc, AgentGroupConfigurat break; } + // I18: announce-bid-award for this wave's unassigned BID-mode tasks — + // runs before the grouping below so awarded tasks join the same wave. + runBidRoundIfNeeded(gc, config, phase, protocol, question, phaseIdx, listener, turnCounter, maxTurns); + Map> tasksByAgent = gc.getTaskList().findExecutableTasks().stream() .filter(t -> t.assignedAgentId() != null) .collect(Collectors.groupingBy(TaskItem::assignedAgentId)); @@ -965,6 +985,154 @@ public String formatVerificationForDisplay(String rawContent) { } } + // --- I18: announce-bid-award (CNP-lite) --- + + /** + * Runs one blind, parallel bid round over this wave's unassigned BID-mode tasks + * (I18). Eligible members receive the announced batch with NO transcript and NO + * peer bids (blindness is what makes the self-assessed confidences comparable); + * each task goes to the highest confidence, deterministic tie-break by speaking + * order; a task nobody bid on falls back to the ROLE path — an auction must + * never stall a wave. + *

    + * Skips itself (with a log — a silent cap reads as coverage) when it cannot + * beat its own overhead: fewer than {@link TaskBidEngine#MIN_BIDDERS} eligible + * members or {@link TaskBidEngine#MIN_TASKS} unassigned tasks, or a turn budget + * too small for one bid turn per member. + */ + void runBidRoundIfNeeded(GroupConversation gc, AgentGroupConfiguration config, DiscussionPhase phase, + ProtocolConfig protocol, String question, int phaseIdx, + GroupDiscussionEventListener listener, AtomicInteger turnCounter, int maxTurns) { + SharedTaskList taskList = gc.getTaskList(); + List unassigned = taskList.findExecutableTasks().stream() + .filter(t -> t.assignedAgentId() == null) + .filter(t -> TaskBidEngine.effectiveMode(taskDefinitionFor(config, t), config) == AgentGroupConfiguration.AssignmentMode.BID) + .toList(); + if (unassigned.isEmpty()) { + return; + } + List bidders = config.getMembers().stream() + .filter(m -> m != null && m.memberType() == AgentGroupConfiguration.MemberType.AGENT) + .filter(m -> !m.agentId().equals(config.getModeratorAgentId())) + .toList(); + + if (!TaskBidEngine.auctionWorthwhile(bidders.size(), unassigned.size())) { + LOGGER.infof("Group %s: skipping bid round (%d bidder(s), %d unassigned task(s) — the auction cannot beat " + + "its own overhead); falling back to ROLE assignment", + LogSanitizer.sanitize(gc.getGroupId()), bidders.size(), unassigned.size()); + fallbackRoleAssign(gc, config, unassigned); + return; + } + if (maxTurns > 0 && turnCounter.get() + bidders.size() > maxTurns) { + LOGGER.infof("Group %s: skipping bid round — %d bid turn(s) would exceed the remaining turn budget; " + + "falling back to ROLE assignment", LogSanitizer.sanitize(gc.getGroupId()), bidders.size()); + fallbackRoleAssign(gc, config, unassigned); + return; + } + turnCounter.addAndGet(bidders.size()); + + var announcedSubjects = unassigned.stream().map(TaskItem::subject).collect(Collectors.toSet()); + var cancellation = new MemberTurnCancellation(); + final var bidCaller = callerIdentityContext.captureOrCurrent(); + List>> futures = bidders.stream() + .map(member -> CompletableFuture.supplyAsync(callerIdentityContext.withIdentitySupplying(bidCaller, () -> { + try { + String prompt = TaskBidEngine.buildBidPrompt(unassigned, member, question); + TranscriptEntry reply = memberTurnExecutor.executeAgentTurn(member, gc, prompt, protocol, + phaseIdx, phase, null, listener, cancellation); + if (reply != null && reply.content() != null && !reply.content().isBlank()) { + // Recorded as a BID entry — peer-hidden while the phase + // runs (F4's blind-bid rule), auditable afterwards. + gc.getTranscript().add(new TranscriptEntry(member.agentId(), member.displayName(), + reply.content(), phaseIdx, phase.name(), TranscriptEntryType.BID, + Instant.now(), null, null)); + } + return TaskBidEngine.parseBids(reply != null ? reply.content() : null, member, announcedSubjects); + } catch (Exception e) { + LOGGER.warnf("Bid turn failed for '%s': %s — casting no bids", + LogSanitizer.sanitize(member.agentId()), LogSanitizer.sanitize(e.getMessage())); + return List.of(); + } + }), executorService)).toList(); + + List allBids = new ArrayList<>(); + long deadlineNanos = System.nanoTime() + + TimeUnit.SECONDS.toNanos(GroupConversationService.parallelBatchBudgetSeconds(protocol)); + for (CompletableFuture> future : futures) { + try { + long remainingNanos = Math.max(0, deadlineNanos - System.nanoTime()); + allBids.addAll(future.get(remainingNanos, TimeUnit.NANOSECONDS)); + } catch (TimeoutException e) { + cancellation.cancel(); + LOGGER.warnf("Group %s: bid round timed out — late bidders cast no bids", + LogSanitizer.sanitize(gc.getGroupId())); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + cancellation.cancel(); + break; + } catch (ExecutionException e) { + LOGGER.warnf("Group %s: bid future failed: %s", + LogSanitizer.sanitize(gc.getGroupId()), LogSanitizer.sanitize(e.getMessage())); + } + } + + Map awards = TaskBidEngine.award(unassigned, allBids); + synchronized (taskList) { + for (TaskItem task : unassigned) { + SharedTaskList.AwardedBid winner = awards.get(task.id()); + if (winner != null) { + GroupMember member = findMemberIncludingDynamic(config.getMembers(), gc, winner.agentId()); + taskList.assignTask(task.id(), winner.agentId(), + member != null ? member.displayName() : winner.agentId()); + taskList.recordAwardedBid(task.id(), winner); + LOGGER.infof("Group %s: task '%s' awarded to '%s' (confidence %.2f)", + LogSanitizer.sanitize(gc.getGroupId()), LogSanitizer.sanitize(task.subject()), + LogSanitizer.sanitize(winner.agentId()), winner.confidence()); + } + } + } + // Tasks nobody bid on fall back to ROLE — never stall the wave on silence. + fallbackRoleAssign(gc, config, unassigned.stream() + .filter(t -> !awards.containsKey(t.id())) + .toList()); + } + + /** + * The TaskDefinition a task was created from, matched by subject; null for + * planned/filed tasks. + */ + private TaskDefinition taskDefinitionFor(AgentGroupConfiguration config, TaskItem task) { + if (config.getTasks() == null) { + return null; + } + return config.getTasks().stream() + .filter(td -> td.subject().equalsIgnoreCase(task.subject())) + .findFirst().orElse(null); + } + + /** + * ROLE/round-robin assignment for tasks the auction did not (or could not) + * award. + */ + private void fallbackRoleAssign(GroupConversation gc, AgentGroupConfiguration config, List tasks) { + SharedTaskList taskList = gc.getTaskList(); + synchronized (taskList) { + for (int i = 0; i < tasks.size(); i++) { + TaskItem task = tasks.get(i); + TaskDefinition td = taskDefinitionFor(config, task); + String agentId = resolveAssignee(td != null ? td.assignToRole() : null, + config.getMembers(), config.getModeratorAgentId(), i); + if (agentId != null) { + GroupMember member = findMember(config.getMembers(), agentId); + taskList.assignTask(task.id(), agentId, member != null ? member.displayName() : agentId); + } else { + LOGGER.warnf("Group %s: no fallback assignee for task '%s'", + LogSanitizer.sanitize(gc.getGroupId()), LogSanitizer.sanitize(task.subject())); + } + } + } + } + // --- Task assignment helpers --- /** diff --git a/src/main/java/ai/labs/eddi/engine/internal/groups/VoteTallyEngine.java b/src/main/java/ai/labs/eddi/engine/internal/groups/VoteTallyEngine.java new file mode 100644 index 0000000000..09d01b003d --- /dev/null +++ b/src/main/java/ai/labs/eddi/engine/internal/groups/VoteTallyEngine.java @@ -0,0 +1,367 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.engine.internal.groups; + +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.OptionsSource; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.VoteConfig; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.VoteMethod; +import ai.labs.eddi.configs.groups.model.GroupConversation.DecisionRecord; +import ai.labs.eddi.configs.groups.model.GroupConversation.DecisionType; +import ai.labs.eddi.configs.groups.model.GroupConversation.Dissent; +import ai.labs.eddi.configs.groups.model.GroupConversation.TranscriptEntry; +import ai.labs.eddi.configs.groups.model.GroupConversation.TranscriptEntryType; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import org.jboss.logging.Logger; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parses ballots and produces the vote's {@link DecisionRecord} (I14). + *

    + * LLM ballots are correlated (shared priors, sycophancy), so what this class + * protects is the auditable process artifact: the weighted tally, every + * raw ballot, and the losing side's statements as dissents. Epistemics are not + * claimed. Every parse failure degrades honestly — an unreadable reply is a + * non-ballot that counts against quorum, never a guessed vote. + *

    + * Mirrors {@link DebateVerdictParser}'s discipline: three-tier parse (strict + * JSON → JSON embedded in prose/fence → give up), {@code + * FAIL_ON_TRAILING_TOKENS} so a reply carrying two ballots is ambiguous rather + * than first-wins, and no failure mode that kills the discussion. + * + * @author ginccc + */ +public final class VoteTallyEngine { + + private static final Logger LOGGER = Logger.getLogger(VoteTallyEngine.class); + + /** The mechanism tag on a decision produced purely by counting ballots. */ + public static final String METHOD_VOTE = "vote"; + /** The mechanism tag when a moderator broke the tie (or quorum failure). */ + public static final String METHOD_TIEBREAK = "vote+moderator-tiebreak"; + + /** Two weighted totals within this of each other are the same total (a tie). */ + private static final double TALLY_EPSILON = 1e-9; + + /** {@code Option A: text} / {@code Option 2 - text} lines in a synthesis. */ + private static final Pattern OPTION_LINE = Pattern.compile("^\\s*Option\\s+([A-Za-z0-9]+)\\s*[:\\-]\\s*(.+?)\\s*$", + Pattern.MULTILINE); + /** A ballot voting by label ("Option A") instead of the option's text. */ + private static final Pattern OPTION_LABEL = Pattern.compile("^\\s*Option\\s+([A-Za-z0-9]+)\\s*$", Pattern.CASE_INSENSITIVE); + + private static final ObjectMapper MAPPER = JsonMapper.builder() + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .build(); + + private VoteTallyEngine() { + } + + /** One parsed ballot. {@code votes} hold canonical option texts. */ + public record Ballot(String agentId, String displayName, List votes, Double confidence, String statement) { + } + + /** + * The tally's verdict on what happened, before any tie policy runs. + * + * @param decision + * the record to store when {@code unresolvedOptions} is empty — a + * decided VOTE, or a {@code NONE} record explaining why not + * @param unresolvedOptions + * non-empty when a tie policy must choose: the tied options, or + * every option on a quorum failure + * @param quorumReached + * whether enough valid ballots arrived + * @param ballots + * every parsed ballot. Carried so a tie policy that DOES pick a + * winner can compute the losing side's dissents against its choice — + * the unresolved decision's own dissent list is necessarily empty, + * and reusing it silently dropped the minority report for exactly + * the closest votes + */ + public record TallyOutcome(DecisionRecord decision, List unresolvedOptions, boolean quorumReached, + List ballots) { + } + + /** + * The ballot options: {@code EXPLICIT} takes the config verbatim; + * {@code LAST_SYNTHESIS} extracts {@code Option X: …} lines from the newest + * SYNTHESIS entry. An empty result means the vote cannot run. + */ + public static List resolveOptions(VoteConfig config, List transcript) { + if (config.optionsSource() == OptionsSource.EXPLICIT) { + return config.options(); + } + for (int i = transcript.size() - 1; i >= 0; i--) { + TranscriptEntry entry = transcript.get(i); + if (entry != null && entry.type() == TranscriptEntryType.SYNTHESIS && entry.content() != null) { + List options = new ArrayList<>(); + Matcher matcher = OPTION_LINE.matcher(entry.content()); + while (matcher.find()) { + options.add(matcher.group(2).trim()); + } + return options; + } + } + return List.of(); + } + + /** The JSON contract line rendered into the ballot prompt. */ + public static String ballotContract(VoteMethod method) { + return method == VoteMethod.APPROVAL + ? "{\"votes\": [\"\", \"...\"], \"confidence\": <0..1>, \"statement\": \"\"}" + : "{\"vote\": \"\", \"confidence\": <0..1>, \"statement\": \"\"}"; + } + + /** + * Three-tier ballot parse. Returns {@code null} for a non-ballot — which counts + * against quorum, never as a guessed vote. + */ + static Ballot parseBallot(TranscriptEntry entry, List options, VoteMethod method) { + if (entry == null || entry.content() == null || entry.content().isBlank()) { + return null; + } + String content = entry.content(); + + JsonNode node = readJson(content); + if (node == null) { + node = readJson(embeddedJson(content)); + } + if (node != null && node.isObject()) { + List votes = new ArrayList<>(); + if (method == VoteMethod.APPROVAL && node.path("votes").isArray()) { + for (JsonNode vote : node.path("votes")) { + String canonical = canonicalOption(vote.asText(), options); + if (canonical == null) { + return null; // an out-of-contract vote makes the whole ballot unreadable + } + if (!votes.contains(canonical)) { + votes.add(canonical); + } + } + } else if (node.path("vote").isTextual()) { + String canonical = canonicalOption(node.path("vote").asText(), options); + if (canonical == null) { + return null; + } + votes.add(canonical); + } + if (!votes.isEmpty()) { + Double confidence = node.path("confidence").isNumber() + ? Math.max(0.0, Math.min(1.0, node.path("confidence").asDouble())) + : null; + String statement = node.path("statement").isTextual() ? node.path("statement").asText() : null; + return new Ballot(entry.speakerAgentId(), entry.speakerDisplayName(), List.copyOf(votes), confidence, statement); + } + } + + // Tier 2: exactly ONE option's text appears in the reply. Two or more is + // ambiguous — refusing to pick by position is the point of this tier. + String scanned = null; + String lower = content.toLowerCase(Locale.ROOT); + for (String option : options) { + if (lower.contains(option.toLowerCase(Locale.ROOT))) { + if (scanned != null) { + return null; + } + scanned = option; + } + } + if (scanned != null) { + return new Ballot(entry.speakerAgentId(), entry.speakerDisplayName(), List.of(scanned), null, null); + } + return null; + } + + /** + * Weighted tally over one VOTE phase repeat. + * + * @param voteEntries + * the repeat's VOTE transcript entries (one per participant that + * replied) + * @param participantCount + * the quorum denominator — everyone who was ASKED, so abstentions + * and unparseable replies count against quorum + */ + public static TallyOutcome tally(List voteEntries, int participantCount, List options, + VoteConfig config, String phaseName) { + List ballots = new ArrayList<>(); + for (TranscriptEntry entry : voteEntries) { + Ballot ballot = parseBallot(entry, options, config.method()); + if (ballot != null) { + ballots.add(ballot); + } else if (entry != null) { + LOGGER.debugf("Non-ballot reply from '%s' counts against quorum", entry.speakerAgentId()); + } + } + + Map totals = new LinkedHashMap<>(); + for (String option : options) { + totals.put(option, 0.0); + } + List> rawBallots = new ArrayList<>(); + for (Ballot ballot : ballots) { + double weight = config.weights().getOrDefault(ballot.agentId(), 1.0); + if (config.weightByConfidence() && ballot.confidence() != null) { + weight *= ballot.confidence(); + } + for (String vote : ballot.votes()) { + totals.merge(vote, weight, Double::sum); + } + Map raw = new LinkedHashMap<>(); + raw.put("agentId", ballot.agentId()); + raw.put("votes", ballot.votes()); + if (ballot.confidence() != null) { + raw.put("confidence", ballot.confidence()); + } + raw.put("weight", weight); + if (ballot.statement() != null && !ballot.statement().isBlank()) { + raw.put("statement", ballot.statement()); + } + rawBallots.add(raw); + } + + Map tallyMap = new LinkedHashMap<>(); + tallyMap.put("totals", totals); + tallyMap.put("ballots", rawBallots); + tallyMap.put("participants", participantCount); + tallyMap.put("validBallots", ballots.size()); + tallyMap.put("quorum", config.quorum()); + + boolean quorumReached = participantCount > 0 && (double) ballots.size() / participantCount >= config.quorum(); + tallyMap.put("quorumReached", quorumReached); + + if (!quorumReached) { + var decision = new DecisionRecord(DecisionType.NONE, + "Quorum not reached: %d of %d participants cast a valid ballot (needed %.0f%%)." + .formatted(ballots.size(), participantCount, config.quorum() * 100), + null, tallyMap, List.of(), METHOD_VOTE, phaseName, null); + return new TallyOutcome(decision, List.copyOf(options), false, List.copyOf(ballots)); + } + + double max = totals.values().stream().mapToDouble(Double::doubleValue).max().orElse(0.0); + // Tolerance, not ==: weighted and confidence-weighted totals are sums of + // non-representable doubles, so two mathematically equal totals can differ + // in the last bit — and a real tie would then silently crown one side. + List leaders = totals.entrySet().stream() + .filter(e -> Math.abs(e.getValue() - max) < TALLY_EPSILON && max > 0.0) + .map(Map.Entry::getKey).toList(); + + if (leaders.size() != 1) { + var decision = new DecisionRecord(DecisionType.NONE, + leaders.isEmpty() ? "No option received any weighted vote." : "Tie between: " + String.join(" / ", leaders) + ".", + null, tallyMap, List.of(), METHOD_VOTE, phaseName, null); + return new TallyOutcome(decision, leaders.isEmpty() ? List.copyOf(options) : leaders, true, List.copyOf(ballots)); + } + + String winner = leaders.get(0); + var decision = new DecisionRecord(DecisionType.VOTE, + "\"%s\" wins with %.2f of %.2f weighted votes.".formatted(winner, totals.get(winner), + totals.values().stream().mapToDouble(Double::doubleValue).sum()), + winner, tallyMap, losingDissents(ballots, winner), METHOD_VOTE, phaseName, null); + return new TallyOutcome(decision, List.of(), true, List.copyOf(ballots)); + } + + /** + * The minority report: losing-side ballots that carried a statement become the + * decision's dissents. Package-visible so {@code PhaseExecutionEngine}'s + * moderator tiebreak can compute dissents against ITS chosen option. + */ + static List losingDissents(List ballots, String winner) { + List dissents = new ArrayList<>(); + for (Ballot ballot : ballots) { + if (!ballot.votes().contains(winner) && ballot.statement() != null && !ballot.statement().isBlank()) { + dissents.add(new Dissent(ballot.agentId(), ballot.displayName(), ballot.statement())); + } + } + return dissents; + } + + /** + * A tiebreaker's free-text choice, resolved by the same exact-scan rule as + * ballot tier 2: exactly one option named, or no choice at all. + */ + public static String resolveChoice(String reply, List options) { + if (reply == null || reply.isBlank()) { + return null; + } + String canonical = canonicalOption(reply.trim(), options); + if (canonical != null) { + return canonical; + } + String lower = reply.toLowerCase(Locale.ROOT); + String scanned = null; + for (String option : options) { + if (lower.contains(option.toLowerCase(Locale.ROOT))) { + if (scanned != null) { + return null; + } + scanned = option; + } + } + return scanned; + } + + /** + * Matches a vote string to its canonical option — by text, or by "Option X" + * label position. + */ + private static String canonicalOption(String vote, List options) { + if (vote == null) { + return null; + } + String trimmed = vote.trim(); + for (String option : options) { + if (option.equalsIgnoreCase(trimmed)) { + return option; + } + } + Matcher label = OPTION_LABEL.matcher(trimmed); + if (label.matches()) { + String index = label.group(1).toUpperCase(Locale.ROOT); + int position = index.length() == 1 && Character.isLetter(index.charAt(0)) + ? index.charAt(0) - 'A' + : parseIndex(index); + if (position >= 0 && position < options.size()) { + return options.get(position); + } + } + return null; + } + + private static int parseIndex(String index) { + try { + return Integer.parseInt(index) - 1; + } catch (NumberFormatException e) { + return -1; + } + } + + private static JsonNode readJson(String text) { + if (text == null || text.isBlank()) { + return null; + } + try { + return MAPPER.readTree(text.trim()); + } catch (Exception e) { + return null; + } + } + + /** The first balanced-looking JSON object embedded in prose or a fence. */ + private static String embeddedJson(String content) { + int start = content.indexOf('{'); + int end = content.lastIndexOf('}'); + return start >= 0 && end > start ? content.substring(start, end + 1) : null; + } +} diff --git a/src/main/java/ai/labs/eddi/engine/lifecycle/GroupConversationEventSink.java b/src/main/java/ai/labs/eddi/engine/lifecycle/GroupConversationEventSink.java index 8182bcebde..1330924030 100644 --- a/src/main/java/ai/labs/eddi/engine/lifecycle/GroupConversationEventSink.java +++ b/src/main/java/ai/labs/eddi/engine/lifecycle/GroupConversationEventSink.java @@ -61,6 +61,27 @@ private GroupConversationEventSink() { * Always preceded by an {@link #EVENT_CONVERGENCE_CHECKED} for the same repeat. */ public static final String EVENT_CONVERGENCE_REACHED = "convergence_reached"; + /** + * A HUMAN group member's turn is up (I6): the discussion paused + * ({@code AWAITING_HUMAN_INPUT}) until that member submits their response — or + * the group's {@code humanMemberConfig} timeout policy resolves the turn. + * Distinct from {@link #EVENT_AWAITING_APPROVAL}: this is "you're up", not + * "approve/reject". + */ + public static final String EVENT_HUMAN_INPUT_REQUESTED = "human_input_requested"; + /** + * A RETRO phase harvested lessons into team-owned group memory (I8). Fires even + * with zero lessons stored — "the retro ran and found nothing durable" is + * itself signal for an observer. + */ + public static final String EVENT_RETRO_RECORDED = "retro_recorded"; + /** + * A member created or updated a shared artifact (I17). Fired by the turn + * executor after the turn that made the write — tools have no listener + * reference, so accepted writes ride the live discussion's artifact-change + * queue until the executor drains it. + */ + public static final String EVENT_ARTIFACT_UPDATED = "artifact_updated"; // --- Event payloads --- @@ -119,6 +140,15 @@ public record HitlPauseEvent(int phaseIndex, String phaseName, String reason, St public record HitlResumeEvent(String verdict, String note, String decidedBy) { } + /** + * A HUMAN member's turn is up (I6). Carries identifiers only — the rendered + * prompt lives on the conversation's {@code pendingHumanInput}, which the + * member's UI reads; an SSE frame is the wrong place for a full transcript + * rendering. + */ + public record HumanInputRequestedEvent(String memberId, String displayName, int phaseIndex, String phaseName) { + } + /** * Emitted when a member agent's private conversation requested human approval * (PAUSE_CONVERSATION) during its group turn. Member-level HITL is not @@ -159,4 +189,21 @@ public record ConvergenceCheckedEvent(int phaseIndex, String phaseName, int repe */ public record ConvergenceReachedEvent(int phaseIndex, String phaseName, int repeat, int repeatsSkipped, String reason) { } + + /** A RETRO phase stored lessons into team-owned group memory (I8). */ + public record RetroRecordedEvent(String groupId, String phaseName, int lessonsStored) { + } + + /** + * A member created or updated a shared artifact (I17). Carries metadata only, + * never the content — an SSE observer reads the artifact through the REST + * payload, and content can be a quarter megabyte. + * + * @param created + * {@code true} for a fresh artifact (v1), {@code false} for an + * accepted update + */ + public record ArtifactUpdatedEvent(String artifactId, String name, String type, long version, String editorAgentId, + String status, boolean created) { + } } diff --git a/src/main/java/ai/labs/eddi/engine/mcp/McpGroupTools.java b/src/main/java/ai/labs/eddi/engine/mcp/McpGroupTools.java index d81baf2809..30b6610d71 100644 --- a/src/main/java/ai/labs/eddi/engine/mcp/McpGroupTools.java +++ b/src/main/java/ai/labs/eddi/engine/mcp/McpGroupTools.java @@ -4,8 +4,16 @@ */ package ai.labs.eddi.engine.mcp; +import ai.labs.eddi.configs.groups.IGroupWorkspaceStore; import ai.labs.eddi.configs.groups.IRestAgentGroupStore; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration; +import ai.labs.eddi.configs.groups.model.GroupWorkspace; +import ai.labs.eddi.configs.groups.templates.GroupTemplateService; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import ai.labs.eddi.configs.groups.model.SharedTaskList; +import ai.labs.eddi.configs.groups.rest.RestGroupWorkspace; +import ai.labs.eddi.configs.groups.model.SharedTaskList.TaskItem; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.TaskDefinition; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.DiscussionStyle; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.GroupMember; @@ -31,6 +39,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import static ai.labs.eddi.engine.mcp.McpToolUtils.*; @@ -46,23 +55,29 @@ public class McpGroupTools { private static final Logger LOGGER = Logger.getLogger(McpGroupTools.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); private final IRestAgentGroupStore groupStore; private final IGroupConversationService groupConversationService; private final IJsonSerialization jsonSerialization; private final SecurityIdentity identity; private final OwnershipValidator ownershipValidator; + private final IGroupWorkspaceStore workspaceStore; + private final GroupTemplateService templateService; private final boolean authEnabled; @Inject public McpGroupTools(IRestAgentGroupStore groupStore, IGroupConversationService groupConversationService, IJsonSerialization jsonSerialization, - SecurityIdentity identity, OwnershipValidator ownershipValidator, + SecurityIdentity identity, OwnershipValidator ownershipValidator, IGroupWorkspaceStore workspaceStore, + GroupTemplateService templateService, @ConfigProperty(name = "authorization.enabled", defaultValue = "false") boolean authEnabled) { this.groupStore = groupStore; this.groupConversationService = groupConversationService; this.jsonSerialization = jsonSerialization; this.identity = identity; this.ownershipValidator = ownershipValidator; + this.workspaceStore = workspaceStore; + this.templateService = templateService; this.authEnabled = authEnabled; } @@ -518,4 +533,121 @@ public String close_group_conversation( return errorJson("Failed to close group conversation", "INTERNAL", null); } } + + // ================================================================= + // I13 — standing-team workspace (backlog for human PMs) + // ================================================================= + + @Tool(description = "Add a task to a standing team's backlog (I13). The backlog persists across " + + "discussions; scheduled cadences pull executable tasks from it into task-force runs. " + + "Higher priority runs earlier. Returns the created task.") + @Blocking + public String add_team_task(@ToolArg(description = "Group configuration ID") String groupId, + @ToolArg(description = "Task subject (short, unique within the backlog)") String subject, + @ToolArg(description = "Task description (optional)") String description, + @ToolArg(description = "Priority, higher runs earlier (default 0)") String priority) { + requireRole(identity, authEnabled, "eddi-editor"); + try { + if (subject == null || subject.isBlank()) { + return errorJson("subject is required"); + } + if (subject.trim().length() > SharedTaskList.MAX_AGENT_TASK_SUBJECT_LENGTH) { + return errorJson("subject exceeds " + SharedTaskList.MAX_AGENT_TASK_SUBJECT_LENGTH + " characters"); + } + if (description != null && description.length() > SharedTaskList.MAX_AGENT_TASK_DESCRIPTION_LENGTH) { + return errorJson("description exceeds " + SharedTaskList.MAX_AGENT_TASK_DESCRIPTION_LENGTH + " characters"); + } + if (groupStore.getCurrentResourceId(groupId) == null) { + return errorJson("Group not found: " + groupId); + } + String trimmedSubject = subject.trim(); + // Optimistic-concurrency retry — same reasoning as the REST surface: a + // lost CAS re-reads and re-validates so concurrent adds cannot drop + // each other or slip past the cap. + for (int attempt = 0; attempt < RestGroupWorkspace.MAX_CAS_ATTEMPTS; attempt++) { + var workspace = workspaceStore.readOrCreate(groupId); + if (workspace.getBacklog().size() >= GroupWorkspace.MAX_BACKLOG_SIZE) { + return errorJson("The backlog already holds " + GroupWorkspace.MAX_BACKLOG_SIZE + + " tasks — complete or delete existing tasks before adding more"); + } + if (workspace.getBacklog().getTasks().stream().anyMatch(t -> trimmedSubject.equalsIgnoreCase(t.subject()))) { + return errorJson("A backlog task with that subject already exists — writeback matches outcomes " + + "by subject, so subjects must be unique"); + } + var task = workspace.getBacklog().addTask(new TaskItem( + trimmedSubject, description != null ? description : "", parseIntOrDefault(priority, 0))); + if (workspaceStore.casRevision(workspace)) { + return jsonSerialization.serialize(task); + } + } + return errorJson("The workspace is being modified concurrently — retry the request"); + } catch (Exception e) { + LOGGER.errorf("add_team_task failed: %s", e.getMessage()); + return errorJson(e.getMessage()); + } + } + + @Tool(description = "List a standing team's backlog (I13): every task with its status, priority, " + + "assignee and verification outcome.") + @Blocking + public String list_team_backlog(@ToolArg(description = "Group configuration ID") String groupId) { + requireRole(identity, authEnabled, "eddi-viewer"); + try { + if (groupStore.getCurrentResourceId(groupId) == null) { + return errorJson("Group not found: " + groupId); + } + var workspace = workspaceStore.find(groupId); + return jsonSerialization.serialize( + workspace != null ? workspace.getBacklog().getTasks() : List.of()); + } catch (Exception e) { + LOGGER.errorf("list_team_backlog failed: %s", e.getMessage()); + return errorJson(e.getMessage()); + } + } + + // ================================================================= + // I10 — org/team preset templates + // ================================================================= + + @Tool(description = "List the packaged group templates (I10): research-pod, editorial-team, ops-task-force, " + + "decision-board, negotiation-table. Each entry names its required roles — the keys " + + "create_group_from_template expects.") + public String list_group_templates() { + requireRole(identity, authEnabled, "eddi-viewer"); + try { + return jsonSerialization.serialize(templateService.list()); + } catch (Exception e) { + LOGGER.errorf("list_group_templates failed: %s", e.getMessage()); + return errorJson(e.getMessage()); + } + } + + @Tool(description = "Create a group from a packaged template (I10) by assigning agents to its named roles. " + + "roleAssignments is a JSON object mapping role -> agent id (for HUMAN roles: the principal id). " + + "Saves through the normal store path, so every save-time validation applies.") + @Blocking + public String create_group_from_template(@ToolArg(description = "Template id, e.g. 'research-pod'") String templateId, + @ToolArg(description = "Name for the new group (optional)") String name, + @ToolArg(description = "JSON object: role -> agent id") String roleAssignments) { + requireRole(identity, authEnabled, "eddi-editor"); + try { + Map assignments = roleAssignments != null && !roleAssignments.isBlank() + ? MAPPER.readValue(roleAssignments, new TypeReference>() { + }) + : Map.of(); + AgentGroupConfiguration config = templateService.instantiate(templateId, name, assignments); + Response response = groupStore.createGroup(config); + if (response.getStatus() >= 300) { + return errorJson("Group creation failed with status " + response.getStatus()); + } + String location = response.getLocation() != null ? response.getLocation().toString() : ""; + return "Created group '" + config.getName() + "' from template '" + templateId + "'" + + (location.isEmpty() ? "" : " at " + location); + } catch (IllegalArgumentException e) { + return errorJson(e.getMessage()); + } catch (Exception e) { + LOGGER.errorf("create_group_from_template failed: %s", e.getMessage()); + return errorJson(e.getMessage()); + } + } } diff --git a/src/main/java/ai/labs/eddi/engine/mcp/McpHitlTools.java b/src/main/java/ai/labs/eddi/engine/mcp/McpHitlTools.java index dbd4d23d0e..b6a87e3c37 100644 --- a/src/main/java/ai/labs/eddi/engine/mcp/McpHitlTools.java +++ b/src/main/java/ai/labs/eddi/engine/mcp/McpHitlTools.java @@ -337,11 +337,23 @@ public String getGroupApprovalStatus( return errorJson("groupId and conversationId are required", "BAD_REQUEST", null); } try { - hitlAccessGuard.requireGroupConversationHitlAccess(groupId, conversationId); + // I6: the READ guard — the pending HUMAN member may read the status + // of the turn they owe (their rendered prompt is in the summary). + hitlAccessGuard.requireGroupConversationReadAccess(groupId, conversationId); GroupConversation gc = groupConversationService.readGroupConversation(conversationId); - boolean paused = gc.getState() == GroupConversation.GroupConversationState.AWAITING_APPROVAL; + boolean paused = gc.getState() == GroupConversation.GroupConversationState.AWAITING_APPROVAL + || gc.getState() == GroupConversation.GroupConversationState.AWAITING_HUMAN_INPUT; + // The approver full-view window is the APPROVAL pause only — `paused` + // also covers AWAITING_HUMAN_INPUT, where there is nothing for an + // approver to decide and the transcript would leak outside their + // remit (review finding). Summary fields keep the wider predicate. + boolean awaitingApproval = gc.getState() == GroupConversation.GroupConversationState.AWAITING_APPROVAL; if ("full".equals(detail)) { - if (!paused && !ownershipValidator.isAdmin(identity) && !ownershipValidator.isOwner(identity, gc.getUserId())) { + // The pending human member (admitted by the READ guard) does NOT + // get the full view — their working material is the rendered + // prompt in the summary. Same gate as the REST surface. + if (!ownershipValidator.isAdmin(identity) && !ownershipValidator.isOwner(identity, gc.getUserId()) + && !(awaitingApproval && ownershipValidator.isApprover(identity))) { return errorJson("Full approval status is available to approvers only while the group conversation " + "is awaiting approval — use the summary view", "FORBIDDEN", null); } @@ -362,6 +374,13 @@ public String getGroupApprovalStatus( summary.put("pauseReason", paused && gc.getHitlPauseReason() != null ? gc.getHitlPauseReason() : ""); summary.put("timeoutPolicy", paused && gc.getHitlTimeoutPolicy() != null ? gc.getHitlTimeoutPolicy().name() : ""); summary.put("awaitingApprovalTaskIds", awaitingTaskIds); + // I6: a human-turn pause carries WHO is up and WHAT they were asked — + // the summary is that member's working view, no transcript needed. + if (gc.getPendingHumanInput() != null) { + summary.put("pendingMemberId", gc.getPendingHumanInput().memberId()); + summary.put("pendingMemberDisplayName", gc.getPendingHumanInput().displayName()); + summary.put("pendingHumanPrompt", gc.getPendingHumanInput().renderedPrompt()); + } return jsonSerialization.serialize(summary); } catch (ForbiddenException e) { return errorJson("Access denied", "FORBIDDEN", null); @@ -459,6 +478,52 @@ public String approveGroupPhase( } } + @Tool(name = "submit_group_human_input", + description = "Submit a HUMAN group member's response for the turn an AWAITING_HUMAN_INPUT discussion is " + + "waiting on (I6). The response is recorded as the member's transcript entry and the discussion " + + "resumes from the next speaker. Only the pending member's own principal (or an admin) may submit " + + "— this is the member SPEAKING, not an approval.") + @Blocking + public String submitGroupHumanInput( + @ToolArg(description = "Group ID") String groupId, + @ToolArg(description = "Group conversation ID") String conversationId, + @ToolArg(description = "The pending HUMAN member's id (their principal id)") String memberId, + @ToolArg(description = "The member's response text") String content) { + String disabled = disabledIfMutationsOff(); + if (disabled != null) { + return disabled; + } + if (groupId == null || groupId.isBlank() || conversationId == null || conversationId.isBlank() + || memberId == null || memberId.isBlank() || content == null || content.isBlank()) { + return errorJson("groupId, conversationId, memberId and content are required", "BAD_REQUEST", null); + } + try { + hitlAccessGuard.requireGroupHumanInputAccess(groupId, conversationId, memberId); + GroupConversation result = groupConversationService.submitHumanInput( + conversationId, memberId, content, principalWithMcpPrefix()); + meterRegistry.counter("eddi.mcp.hitl.decision", "surface", "group", "verdict", "HUMAN_INPUT").increment(); + return jsonSerialization.serialize(result); + } catch (ForbiddenException e) { + return errorJson("Access denied", "FORBIDDEN", null); + } catch (jakarta.ws.rs.NotFoundException e) { + return errorJson("Group conversation not found", "NOT_FOUND", null); + } catch (IResourceStore.ResourceModifiedException e) { + return errorJson("The group conversation was modified concurrently — reload and retry", "CONFLICT", null); + } catch (ResourceNotFoundException + | ai.labs.eddi.configs.groups.IGroupConversationStore.GroupConversationGoneException e) { + return errorJson("Group conversation not found", "NOT_FOUND", null); + } catch (IGroupConversationService.GroupDiscussionException e) { + return errorJson("Group conversation is not awaiting human input — the turn may have been resolved, " + + "timed out, or cancelled", "WRONG_STATE", null); + } catch (IllegalArgumentException e) { + return errorJson("Invalid submission: the memberId does not match the pending turn, or the content is " + + "blank or too long", "BAD_REQUEST", null); + } catch (Exception e) { + LOGGER.warn("MCP submit_group_human_input failed", e); + return errorJson("Failed to submit human input", "INTERNAL", null); + } + } + @Tool(name = "cancel_group_discussion", description = "Cancel an in-progress or paused group discussion. Attributed to the authenticated caller.") @Blocking diff --git a/src/main/java/ai/labs/eddi/engine/mcp/McpToolFilter.java b/src/main/java/ai/labs/eddi/engine/mcp/McpToolFilter.java index 954ed44a26..da9b10bc2a 100644 --- a/src/main/java/ai/labs/eddi/engine/mcp/McpToolFilter.java +++ b/src/main/java/ai/labs/eddi/engine/mcp/McpToolFilter.java @@ -49,6 +49,8 @@ public class McpToolFilter implements ToolFilter { "list_agent_triggers", "create_agent_trigger", "update_agent_trigger", "delete_agent_trigger", // Group Conversations (Phase 10) "describe_discussion_styles", "list_groups", "read_group", "create_group", "update_group", "delete_group", "discuss_with_group", + "add_team_task", "list_team_backlog", + "list_group_templates", "create_group_from_template", "read_group_conversation", "list_group_conversations", "start_group_discussion", "delete_group_conversation", // Group Conversation Follow-Ups "followup_with_member", "continue_group_discussion", "close_group_conversation", @@ -60,7 +62,7 @@ public class McpToolFilter implements ToolFilter { // HITL approval surface (McpHitlTools) — mirrors the REST HITL endpoints "list_pending_approvals", "get_approval_status", "resume_conversation", "cancel_conversation", "list_group_pending_approvals", "list_all_group_pending_approvals", "get_group_approval_status", - "approve_group_phase", "cancel_group_discussion", + "approve_group_phase", "cancel_group_discussion", "submit_group_human_input", // Persistent user memory (McpMemoryTools) — role-guarded (viewer read / admin // write) + per-user ownership "list_user_memories", "get_visible_memories", "search_user_memories", "get_memory_by_key", diff --git a/src/main/java/ai/labs/eddi/engine/memory/ConversationMemoryUtilities.java b/src/main/java/ai/labs/eddi/engine/memory/ConversationMemoryUtilities.java index 7718105910..3d7eb174fc 100644 --- a/src/main/java/ai/labs/eddi/engine/memory/ConversationMemoryUtilities.java +++ b/src/main/java/ai/labs/eddi/engine/memory/ConversationMemoryUtilities.java @@ -64,6 +64,10 @@ public static ConversationMemorySnapshot convertConversationMemory(IConversation private static ConversationMemorySnapshot getMemorySnapshot(IConversationMemory conversationMemory) { var snapshot = new ConversationMemorySnapshot(); + // The field initialiser is the LEGACY sentinel so key-less stored documents + // read as legacy — a snapshot built from live memory is current by + // definition and must say so explicitly. + snapshot.setSchemaVersion(ConversationMemorySnapshot.CURRENT_SCHEMA_VERSION); if (conversationMemory.getUserId() != null) { snapshot.setUserId(conversationMemory.getUserId()); diff --git a/src/main/java/ai/labs/eddi/engine/memory/model/ConversationMemorySnapshot.java b/src/main/java/ai/labs/eddi/engine/memory/model/ConversationMemorySnapshot.java index be5a772a0c..ff2488f715 100644 --- a/src/main/java/ai/labs/eddi/engine/memory/model/ConversationMemorySnapshot.java +++ b/src/main/java/ai/labs/eddi/engine/memory/model/ConversationMemorySnapshot.java @@ -26,13 +26,26 @@ public class ConversationMemorySnapshot { * resume path. */ public static final int CURRENT_SCHEMA_VERSION = 1; + /** + * The version a stored document claims when its JSON carries no + * {@code schemaVersion} key. Mirrors + * {@code GroupConversation#LEGACY_SCHEMA_VERSION} and exists for the same + * reason: Jackson leaves the field initialiser standing for key-less documents, + * so the initialiser must be the legacy floor and the creation path + * ({@code ConversationMemoryUtilities}) stamps {@link #CURRENT_SCHEMA_VERSION} + * explicitly. While {@code CURRENT} is also {@code 1} this is indistinguishable + * from initialising to CURRENT — but only by coincidence, and the first bump to + * {@code 2} would silently re-create the group side's zero-iteration migration + * bug without this split. + */ + public static final int LEGACY_SCHEMA_VERSION = 1; /** * The shape this specific document was last written in. Checked before a * resume: newer than {@link #CURRENT_SCHEMA_VERSION} refuses (this deployment * predates the document), older runs registered migrations forward. See * {@code ConversationSchemaMigrations}. */ - private int schemaVersion = CURRENT_SCHEMA_VERSION; + private int schemaVersion = LEGACY_SCHEMA_VERSION; private String conversationId; private String agentId; private Integer agentVersion; diff --git a/src/main/java/ai/labs/eddi/engine/model/PendingApprovalSummary.java b/src/main/java/ai/labs/eddi/engine/model/PendingApprovalSummary.java index 04fe92463e..22ba5bda6d 100644 --- a/src/main/java/ai/labs/eddi/engine/model/PendingApprovalSummary.java +++ b/src/main/java/ai/labs/eddi/engine/model/PendingApprovalSummary.java @@ -22,10 +22,21 @@ public class PendingApprovalSummary { private String timeoutPolicy; /** ISO-8601 duration of the configured approval timeout (may be null). */ private String approvalTimeout; - /** null/"RULE" = behavior-rule pause, "TOOL_CALL" = gated tool pause. */ + /** + * null/"RULE" = behavior-rule pause, "TOOL_CALL" = gated tool pause; group + * pauses carry "PHASE"/"TASK", and "HUMAN_TURN" (I6) marks a pending human + * member turn — the inbox's kind discriminator, per the plan's "no third inbox" + * rule. + */ private String pauseType; /** Names only (no arguments) of the gated tool calls — badges inbox lists. */ private List toolNames; + /** + * The HUMAN member a "HUMAN_TURN" pause is waiting on (I6) — lets that member's + * own inbox surface the turn even though they do not own the conversation. + * {@code null} for every other pause kind. + */ + private String pendingMemberId; public PendingApprovalSummary() { } @@ -102,4 +113,10 @@ public List getToolNames() { public void setToolNames(List toolNames) { this.toolNames = toolNames; } + public String getPendingMemberId() { + return pendingMemberId; + } + public void setPendingMemberId(String pendingMemberId) { + this.pendingMemberId = pendingMemberId; + } } diff --git a/src/main/java/ai/labs/eddi/engine/runtime/internal/ScheduleFireExecutor.java b/src/main/java/ai/labs/eddi/engine/runtime/internal/ScheduleFireExecutor.java index bfde36bf13..3e2c11e5b9 100644 --- a/src/main/java/ai/labs/eddi/engine/runtime/internal/ScheduleFireExecutor.java +++ b/src/main/java/ai/labs/eddi/engine/runtime/internal/ScheduleFireExecutor.java @@ -63,6 +63,9 @@ public class ScheduleFireExecutor { @Inject DreamService dreamService; + @Inject + TeamCadenceService teamCadenceService; + /** * Execute a schedule fire. Returns the fire log entry. * @@ -112,6 +115,13 @@ public ScheduleFireLog fire(ScheduleConfiguration schedule, String instanceId, i return fireDreamConsolidation(schedule, instanceId, attemptNumber); } + if (TeamCadenceService.isTeamCadenceSchedule(md)) { + // Team cadence fast-path (I13) — a backlog pull into a group + // discussion, not a conversation turn. Same reasoning and machinery + // as the Dream fast-path above. + return fireTeamCadence(schedule, instanceId, attemptNumber); + } + Instant startedAt = Instant.now(); String fireLogId = UUID.randomUUID().toString(); String conversationId = null; @@ -254,6 +264,61 @@ private ScheduleFireLog fireDreamConsolidation(ScheduleConfiguration schedule, S return fireLog; } + /** + * Run one team-cadence pull (I13) and record the fire. Mirrors + * {@link #fireDreamConsolidation}: a deliberate skip (backlog empty, previous + * discussion still running, lost claim) is COMPLETED with the reason in the + * log; a real failure is FAILED so it retries with backoff and eventually + * dead-letters instead of appearing to run while doing nothing. + */ + private ScheduleFireLog fireTeamCadence(ScheduleConfiguration schedule, String instanceId, int attemptNumber) { + Instant startedAt = Instant.now(); + String status; + String errorMessage = null; + String conversationId = null; + boolean interrupted = false; + + try { + TeamCadenceService.CadenceResult result = teamCadenceService.processScheduledFire(schedule.getMetadata()); + conversationId = result.discussionId(); + if (result.isSuccess()) { + status = ScheduleConfiguration.FireStatus.COMPLETED.name(); + if (result.skippedReason() != null) { + LOGGER.infof("[SCHEDULE] Team cadence '%s' (id=%s) skipped: %s", schedule.getName(), schedule.getId(), + result.skippedReason()); + } else { + LOGGER.infof("[SCHEDULE] Team cadence '%s' (id=%s) started discussion %s with %d task(s)", + schedule.getName(), schedule.getId(), result.discussionId(), result.tasksPulled()); + } + } else { + status = ScheduleConfiguration.FireStatus.FAILED.name(); + errorMessage = result.error(); + LOGGER.errorf("[SCHEDULE] Team cadence failed for schedule '%s' (id=%s): %s", schedule.getName(), + schedule.getId(), errorMessage); + } + } catch (Exception e) { + // Same B2 interrupt reasoning — and the same ordering — as fire() above. + if (e instanceof InterruptedException) { + interrupted = true; + } + status = ScheduleConfiguration.FireStatus.FAILED.name(); + errorMessage = e.getClass().getSimpleName() + ": " + e.getMessage(); + LOGGER.errorf(e, "[SCHEDULE] Team cadence threw for schedule '%s' (id=%s)", schedule.getName(), schedule.getId()); + } + + var fireLog = new ScheduleFireLog(UUID.randomUUID().toString(), schedule.getId(), schedule.getFireId(), + schedule.getNextFire(), startedAt, Instant.now(), status, instanceId, conversationId, errorMessage, + attemptNumber, 0.0); + try { + scheduleStore.logFire(fireLog); + } catch (Exception e) { + LOGGER.errorf(e, "[SCHEDULE] Failed to log team-cadence fire for schedule %s", schedule.getId()); + } finally { + restoreInterrupt(interrupted); + } + return fireLog; + } + private String resolveConversation(ScheduleConfiguration schedule, Environment env) throws Exception { String strategy = schedule.getConversationStrategy(); if (strategy == null) { diff --git a/src/main/java/ai/labs/eddi/engine/runtime/internal/TeamCadenceService.java b/src/main/java/ai/labs/eddi/engine/runtime/internal/TeamCadenceService.java new file mode 100644 index 0000000000..4e40e622f0 --- /dev/null +++ b/src/main/java/ai/labs/eddi/engine/runtime/internal/TeamCadenceService.java @@ -0,0 +1,443 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.engine.runtime.internal; + +import ai.labs.eddi.configs.groups.IGroupConversationStore; +import ai.labs.eddi.configs.groups.IGroupWorkspaceStore; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.TaskDefinition; +import ai.labs.eddi.configs.groups.model.GroupConversation; +import ai.labs.eddi.configs.groups.model.GroupConversation.GroupConversationState; +import ai.labs.eddi.configs.groups.model.GroupWorkspace; +import ai.labs.eddi.configs.groups.model.GroupWorkspace.Cadence; +import ai.labs.eddi.configs.groups.model.GroupWorkspace.MemberStats; +import ai.labs.eddi.configs.groups.model.SharedTaskList; +import ai.labs.eddi.configs.groups.model.SharedTaskList.TaskItem; +import ai.labs.eddi.configs.groups.model.SharedTaskList.TaskStatus; +import ai.labs.eddi.engine.internal.GroupConversationService; +import ai.labs.eddi.modules.templating.ITemplatingEngine; +import ai.labs.eddi.utils.LogSanitizer; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +/** + * Executes team cadences (I13): scheduled pulls from a {@link GroupWorkspace}'s + * backlog into a group discussion, and the writeback of that discussion's + * outcomes. The scheduling itself rides {@code SchedulePollerService} whole — + * cluster-wide claim, lease, retry/backoff, dead-lettering and fire logs come + * free, exactly the DreamService precedent; this class is only the fire + * handler. + *

    + * The run protocol is crash-proof by construction: + *

      + *
    1. Reconcile: if the workspace records a running discussion, read it. + * Terminal → write its outcomes back and release the claim. Still running (or + * paused at an HITL gate) → skip this fire; a discussion may legitimately wait + * on a human for days.
    2. + *
    3. Pull: top-N executable backlog tasks by priority. Empty → skip + * (logged) — a cadence with no work must not burn a discussion.
    4. + *
    5. Claim: conditional store write on {@code runningDiscussionId} — + * two pods firing concurrently cannot both start (no in-JVM locks).
    6. + *
    7. Run: the pulled tasks are injected as a runtime copy of + * {@code config.tasks}; the cadence's dollar ceiling rides the + * inherited-ceiling slot (dollar-primary, per the Dream precedent).
    8. + *
    + * Writeback therefore happens on the fire AFTER the discussion ends (or on a + * workspace read — see the REST layer's read-repair), never from inside the + * discussion thread: a pod crash mid-discussion loses nothing, because the next + * fire finds the terminal state and reconciles it. + * + * @author ginccc + */ +@ApplicationScoped +public class TeamCadenceService { + + private static final Logger LOGGER = Logger.getLogger(TeamCadenceService.class); + + /** + * Metadata key marking a schedule as cadence-managed — the contract between + * schedule authors (the workspace REST layer) and the dispatcher + * ({@code ScheduleFireExecutor}), mirroring {@code DreamService}'s. + */ + public static final String METADATA_TYPE_KEY = "teamCadenceType"; + /** Metadata value for team-cadence schedules. */ + public static final String METADATA_TYPE_CADENCE = "team_cadence"; + public static final String METADATA_GROUP_ID_KEY = "groupId"; + public static final String METADATA_CADENCE_ID_KEY = "cadenceId"; + + /** Fallback identity when a cadence predates {@code createdBy}. */ + public static final String FALLBACK_USER_ID = "system:team-cadence"; + + private final IGroupWorkspaceStore workspaceStore; + private final IGroupConversationStore conversationStore; + private final GroupConversationService groupConversationService; + private final ITemplatingEngine templatingEngine; + private final MeterRegistry meterRegistry; + + private Counter cadenceRunsStarted; + private Counter cadenceRunsSkipped; + private Counter cadenceWritebacks; + + @Inject + public TeamCadenceService(IGroupWorkspaceStore workspaceStore, IGroupConversationStore conversationStore, + GroupConversationService groupConversationService, ITemplatingEngine templatingEngine, + MeterRegistry meterRegistry) { + this.workspaceStore = workspaceStore; + this.conversationStore = conversationStore; + this.groupConversationService = groupConversationService; + this.templatingEngine = templatingEngine; + this.meterRegistry = meterRegistry; + } + + @PostConstruct + void initMetrics() { + cadenceRunsStarted = meterRegistry.counter("eddi_team_cadence_runs_started_total"); + cadenceRunsSkipped = meterRegistry.counter("eddi_team_cadence_runs_skipped_total"); + cadenceWritebacks = meterRegistry.counter("eddi_team_cadence_writebacks_total"); + } + + /** True if the given schedule metadata marks a team-cadence schedule. */ + public static boolean isTeamCadenceSchedule(Map metadata) { + return metadata != null && METADATA_TYPE_CADENCE.equals(metadata.get(METADATA_TYPE_KEY)); + } + + /** + * One fire's outcome. {@code error == null} means the fire itself succeeded — + * including the deliberate skips, which carry a {@code skippedReason} so the + * fire log says WHY nothing ran. + */ + public record CadenceResult(String groupId, String cadenceId, String discussionId, int tasksPulled, + String skippedReason, String error) { + + public boolean isSuccess() { + return error == null; + } + + static CadenceResult skipped(String groupId, String cadenceId, String reason) { + return new CadenceResult(groupId, cadenceId, null, 0, reason, null); + } + + static CadenceResult failed(String groupId, String cadenceId, String error) { + return new CadenceResult(groupId, cadenceId, null, 0, null, error); + } + } + + /** + * Handles one schedule fire. Never throws — every failure lands in the result's + * {@code error}, so the fire log records it and the schedule's retry/backoff + * machinery decides what happens next. + */ + public CadenceResult processScheduledFire(Map metadata) { + String groupId = metadata != null ? String.valueOf(metadata.get(METADATA_GROUP_ID_KEY)) : null; + String cadenceId = metadata != null ? String.valueOf(metadata.get(METADATA_CADENCE_ID_KEY)) : null; + if (groupId == null || "null".equals(groupId) || cadenceId == null || "null".equals(cadenceId)) { + return CadenceResult.failed(groupId, cadenceId, "Schedule metadata names no groupId/cadenceId"); + } + try { + GroupWorkspace workspace = workspaceStore.find(groupId); + if (workspace == null) { + return CadenceResult.failed(groupId, cadenceId, "No workspace exists for group " + groupId); + } + + // 1. Reconcile a previous run before anything else. + if (!reconcile(workspace)) { + cadenceRunsSkipped.increment(); + return CadenceResult.skipped(groupId, cadenceId, + "Previous cadence discussion " + workspace.getRunningDiscussionId() + " is still running"); + } + + Cadence cadence = workspace.getCadences().stream() + .filter(c -> cadenceId.equals(c.cadenceId())) + .findFirst().orElse(null); + if (cadence == null) { + return CadenceResult.failed(groupId, cadenceId, + "Cadence " + cadenceId + " no longer exists on the workspace — delete its schedule"); + } + + // 2. Pull: top-N executable backlog tasks, highest priority first. + List pulled = workspace.getBacklog().findExecutableTasks().stream() + .sorted(Comparator.comparingInt(TaskItem::priority).reversed()) + .limit(cadence.maxBacklogTasksPerRun()) + .toList(); + if (pulled.isEmpty()) { + cadenceRunsSkipped.increment(); + LOGGER.infof("Cadence %s for group %s: no executable backlog tasks — skipping this fire", + LogSanitizer.sanitize(cadenceId), LogSanitizer.sanitize(groupId)); + return CadenceResult.skipped(groupId, cadenceId, "No executable backlog tasks"); + } + + // 3. Run — started BEFORE the claim so the claim can carry the real + // discussion id, then claimed BEFORE any task turns can complete. + // startCadenceDiscussionAsync creates the conversation synchronously + // and only then submits the discussion to the executor, so the id + // exists here while no work has run yet; if the claim then loses, + // the just-started discussion is cancelled before its first turn. + String question = renderQuestion(cadence, workspace, pulled); + String userId = cadence.createdBy() != null && !cadence.createdBy().isBlank() + ? cadence.createdBy() + : FALLBACK_USER_ID; + GroupConversation gc = groupConversationService.startCadenceDiscussionAsync( + groupId, question, userId, toTaskDefinitions(pulled), cadence.maxCostPerRun(), null); + + // 4. Claim: conditional on the persisted idle marker. + String before = workspace.getRunningDiscussionId(); + workspace.setRunningDiscussionId(gc.getId()); + workspace.setPulledTaskIds(pulled.stream().map(TaskItem::id).toList()); + for (TaskItem task : pulled) { + workspace.getBacklog().updateTask(withStatus(task, TaskStatus.IN_PROGRESS)); + } + boolean claimed; + try { + claimed = workspaceStore.casRunningDiscussion(workspace, before); + } catch (Exception e) { + // A store failure during the claim leaves a discussion nobody + // references — cancel it before failing, or it runs unclaimed to + // completion with outcomes no writeback will ever collect. + cancelQuietly(gc.getId()); + throw e; + } + if (!claimed) { + // Another pod claimed between our read and our write — cancel the + // discussion this fire started and stand down. + LOGGER.infof("Cadence %s for group %s lost the run claim — cancelling its just-started discussion %s", + LogSanitizer.sanitize(cadenceId), LogSanitizer.sanitize(groupId), gc.getId()); + cancelQuietly(gc.getId()); + cadenceRunsSkipped.increment(); + return CadenceResult.skipped(groupId, cadenceId, "Lost the run claim to a concurrent fire"); + } + + cadenceRunsStarted.increment(); + LOGGER.infof("Cadence %s for group %s started discussion %s with %d backlog task(s)", + LogSanitizer.sanitize(cadenceId), LogSanitizer.sanitize(groupId), gc.getId(), pulled.size()); + return new CadenceResult(groupId, cadenceId, gc.getId(), pulled.size(), null, null); + } catch (Exception e) { + LOGGER.errorf(e, "Cadence fire failed for group %s / cadence %s", + LogSanitizer.sanitize(groupId), LogSanitizer.sanitize(cadenceId)); + return CadenceResult.failed(groupId, cadenceId, e.getClass().getSimpleName() + ": " + e.getMessage()); + } + } + + /** + * Settles a previous cadence run, if any. Returns {@code true} when the + * workspace is idle (possibly after writing back a finished discussion), + * {@code false} when a previous discussion is genuinely still in flight. + *

    + * Public beyond the fire path: the REST layer runs this as read-repair so a + * human looking at the workspace sees settled metrics, not "running" state for + * a discussion that ended an hour ago. + */ + public boolean reconcile(GroupWorkspace workspace) { + String runningId = workspace.getRunningDiscussionId(); + if (runningId == null || runningId.isEmpty()) { + return true; + } + GroupConversation gc; + try { + gc = conversationStore.read(runningId); + } catch (Exception e) { + // Deleted or unreadable — release the claim and return the pulled + // tasks; holding the workspace hostage to a vanished discussion would + // stall every future fire. + LOGGER.warnf("Cadence discussion %s for group %s is gone (%s) — releasing the claim", + runningId, LogSanitizer.sanitize(workspace.getGroupId()), e.getClass().getSimpleName()); + return writebackFailure(workspace, null); + } + return switch (gc.getState()) { + // A lost settle race means another reconciler (or a fresh claim) got + // there first — this caller must treat the workspace as busy and let + // the next fire read fresh state. + case COMPLETED -> writebackCompleted(workspace, gc); + case FAILED, CANCELLED -> writebackFailure(workspace, gc); + default -> false; // IN_PROGRESS, SYNTHESIZING, AWAITING_* — still running + }; + } + + /** + * Writeback for a COMPLETED cadence discussion: match every pulled backlog task + * against the discussion's task list by subject — VERIFIED outcomes stay + * VERIFIED on the backlog (and credit the assignee's stats); anything else + * returns to PENDING with the reviewer feedback appended to the description, + * which is the cross-run retry loop. + */ + boolean writebackCompleted(GroupWorkspace workspace, GroupConversation gc) { + String settledDiscussionId = workspace.getRunningDiscussionId(); + SharedTaskList discussionTasks = gc.getTaskList(); + for (String pulledId : workspace.getPulledTaskIds()) { + TaskItem backlogTask = workspace.getBacklog().findById(pulledId); + if (backlogTask == null) { + continue; + } + TaskItem outcome = discussionTasks == null + ? null + : discussionTasks.getTasks().stream() + .filter(t -> backlogTask.subject().equals(t.subject())) + .findFirst().orElse(null); + if (outcome != null && outcome.verified()) { + workspace.getBacklog().updateTask(new TaskItem(backlogTask.id(), backlogTask.subject(), + backlogTask.description(), TaskStatus.VERIFIED, outcome.assignedAgentId(), + outcome.assignedDisplayName(), backlogTask.dependsOnIds(), outcome.result(), + outcome.verificationNote(), true, backlogTask.priority(), backlogTask.createdAt(), + Instant.now())); + workspace.getMetrics().setTasksVerified(workspace.getMetrics().getTasksVerified() + 1); + if (outcome.assignedAgentId() != null) { + memberStats(workspace, outcome.assignedAgentId()).setTasksVerified( + memberStats(workspace, outcome.assignedAgentId()).getTasksVerified() + 1); + } + } else { + String feedback = outcome != null && outcome.verificationNote() != null + ? outcome.verificationNote() + : (outcome != null && outcome.result() != null ? outcome.result() : "not completed this run"); + workspace.getBacklog().updateTask(new TaskItem(backlogTask.id(), backlogTask.subject(), + appendFeedbackBounded(backlogTask.description(), gc.getId(), feedback), + TaskStatus.PENDING, null, null, backlogTask.dependsOnIds(), null, null, false, + backlogTask.priority(), backlogTask.createdAt(), null)); + if (outcome != null && outcome.assignedAgentId() != null) { + memberStats(workspace, outcome.assignedAgentId()).setTasksFailed( + memberStats(workspace, outcome.assignedAgentId()).getTasksFailed() + 1); + } + } + } + return settle(workspace, gc, settledDiscussionId); + } + + /** + * Writeback for a FAILED/CANCELLED (or vanished) discussion: every pulled task + * returns to PENDING untouched — the work simply did not happen. + */ + boolean writebackFailure(GroupWorkspace workspace, GroupConversation gc) { + String settledDiscussionId = workspace.getRunningDiscussionId(); + for (String pulledId : workspace.getPulledTaskIds()) { + TaskItem backlogTask = workspace.getBacklog().findById(pulledId); + if (backlogTask != null && backlogTask.status() != TaskStatus.VERIFIED) { + workspace.getBacklog().updateTask(withStatus(backlogTask, TaskStatus.PENDING)); + } + } + return settle(workspace, gc, settledDiscussionId); + } + + /** + * @return {@code true} if THIS caller's writeback won — the persisted claim + * still named the discussion being settled. {@code false} means a + * concurrent reconcile (schedule fire on another pod, or the REST + * read-repair) settled it first, or a new run already claimed the + * workspace; either way this caller's mutations are stale and MUST die + * unwritten — an unconditional write here clobbered a live claim, + * orphaning its discussion's outcomes (final-review finding). + */ + private boolean settle(GroupWorkspace workspace, GroupConversation gc, String settledDiscussionId) { + var metrics = workspace.getMetrics(); + metrics.setDiscussions(metrics.getDiscussions() + 1); + metrics.setLastRunAt(Instant.now()); + if (gc != null) { + metrics.setTotalCost(metrics.getTotalCost() + gc.getTotalCost()); + } + workspace.setRunningDiscussionId(GroupWorkspace.NO_RUNNING_DISCUSSION); + workspace.setPulledTaskIds(List.of()); + try { + if (!workspaceStore.casRunningDiscussion(workspace, settledDiscussionId)) { + LOGGER.infof("Cadence writeback for group %s lost the settle race on discussion %s — another " + + "reconciler settled it (or a new run claimed the workspace); dropping stale mutations", + LogSanitizer.sanitize(workspace.getGroupId()), settledDiscussionId); + return false; + } + cadenceWritebacks.increment(); + return true; + } catch (Exception e) { + LOGGER.errorf(e, "Failed to persist cadence writeback for group %s", + LogSanitizer.sanitize(workspace.getGroupId())); + return false; + } + } + + private MemberStats memberStats(GroupWorkspace workspace, String agentId) { + return workspace.getMetrics().getPerMemberStats().computeIfAbsent(agentId, k -> new MemberStats()); + } + + /** One failed run's feedback slice — enough context, never a whole turn. */ + static final int MAX_FEEDBACK_CHARS = 500; + + /** + * Bounded feedback append (final-review finding): {@code outcome.result()} is + * the agent's ENTIRE turn output, and a task failing every run appended it to a + * PERSISTED description each time — unbounded document growth that also rode + * into the next run's PLAN/bid prompts. The feedback is capped per run and the + * accumulated description trimmed from the FRONT (oldest feedback first) to the + * shared task-description ceiling. + */ + static String appendFeedbackBounded(String description, String runId, String feedback) { + String slice = feedback.length() <= MAX_FEEDBACK_CHARS + ? feedback + : feedback.substring(0, MAX_FEEDBACK_CHARS) + "…"; + String combined = (description != null ? description : "") + "\n[Cadence run " + runId + "] " + slice; + int cap = SharedTaskList.MAX_AGENT_TASK_DESCRIPTION_LENGTH; + return combined.length() <= cap ? combined : "…" + combined.substring(combined.length() - cap + 1); + } + + private static TaskItem withStatus(TaskItem task, TaskStatus status) { + return new TaskItem(task.id(), task.subject(), task.description(), status, task.assignedAgentId(), + task.assignedDisplayName(), task.dependsOnIds(), task.result(), task.verificationNote(), + task.verified(), task.priority(), task.createdAt(), task.completedAt()); + } + + /** + * Pulled backlog items as a runtime task plan. Dependencies are dropped on + * purpose: {@code findExecutableTasks} already guaranteed every pulled task's + * dependencies are met, and backlog ids mean nothing inside the discussion. + */ + private static List toTaskDefinitions(List pulled) { + List defs = new ArrayList<>(pulled.size()); + for (TaskItem task : pulled) { + defs.add(new TaskDefinition(task.subject(), task.description(), "ALL", List.of(), task.priority())); + } + return defs; + } + + /** + * The discussion question: the cadence's Qute template rendered over a compact + * backlog summary, or a plain default. A template failure degrades to the + * default — a cadence must not stop pulling work because its prose template + * broke. + */ + private String renderQuestion(Cadence cadence, GroupWorkspace workspace, List pulled) { + StringBuilder summary = new StringBuilder("Work the following backlog tasks:\n"); + for (TaskItem task : pulled) { + summary.append("- ").append(task.subject()); + if (task.description() != null && !task.description().isBlank()) { + summary.append(": ").append(task.description()); + } + summary.append('\n'); + } + if (cadence.inputTemplate() == null || cadence.inputTemplate().isBlank()) { + return summary.toString(); + } + try { + return templatingEngine.processTemplate(cadence.inputTemplate(), Map.of( + "backlogSummary", summary.toString(), + "groupId", workspace.getGroupId(), + "taskCount", pulled.size())); + } catch (Exception e) { + LOGGER.warnf("Cadence %s input template failed to render (%s) — using the plain backlog summary", + LogSanitizer.sanitize(cadence.cadenceId()), LogSanitizer.sanitize(e.getMessage())); + return summary.toString(); + } + } + + private void cancelQuietly(String discussionId) { + try { + groupConversationService.cancelDiscussion(discussionId, null); + } catch (Exception e) { + LOGGER.warnf("Could not cancel discussion %s after a lost cadence claim: %s", discussionId, e.getMessage()); + } + } +} diff --git a/src/main/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListener.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListener.java index b6e8cee99b..c45028e5d4 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListener.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListener.java @@ -4,6 +4,7 @@ */ package ai.labs.eddi.integrations.slack; +import ai.labs.eddi.configs.groups.model.GroupConversation.DecisionRecord; import ai.labs.eddi.configs.groups.model.GroupConversation.DecisionType; import ai.labs.eddi.engine.api.IGroupConversationService.GroupDiscussionEventListener; import ai.labs.eddi.engine.lifecycle.GroupConversationEventSink; @@ -41,7 +42,7 @@ public class SlackGroupDiscussionListener implements GroupDiscussionEventListene * mode (single thread) is too hard to follow with multiple agents. */ private static final Set EXPANDED_STYLES = Set.of( - "ROUND_TABLE", "PEER_REVIEW", "DEVIL_ADVOCATE", "DEBATE", "DELPHI", "TASK_FORCE"); + "ROUND_TABLE", "PEER_REVIEW", "DEVIL_ADVOCATE", "DEBATE", "DELPHI", "TASK_FORCE", "NEGOTIATION"); private final SlackWebApiClient slackApi; private final String authToken; @@ -287,6 +288,7 @@ public void onDecisionReached(GroupConversationEventSink.DecisionReachedEvent ev if (decision.winner() != null && !decision.winner().isBlank()) { sb.append(String.format("Winner: *%s*\n", decision.winner())); } + appendVoteTally(sb, decision); if (decision.dissents() != null && !decision.dissents().isEmpty()) { sb.append(String.format("Dissents: %d\n", decision.dissents().size())); } @@ -295,6 +297,110 @@ public void onDecisionReached(GroupConversationEventSink.DecisionReachedEvent ev postSafe(channelId, threadTs, sb.toString().stripTrailing()); } + /** + * The per-option tally block for VOTE decisions (I14). Bounded and defensive: + * the tally map is model-shaped data that crossed serialization, so every read + * is instanceof-guarded rather than cast. + */ + private static void appendVoteTally(StringBuilder sb, DecisionRecord decision) { + if (decision.type() != DecisionType.VOTE || decision.tally() == null) { + return; + } + Object totals = decision.tally().get("totals"); + if (totals instanceof Map totalsMap && !totalsMap.isEmpty()) { + sb.append("Tally:\n"); + totalsMap.entrySet().stream().limit(MAX_TALLY_LINES).forEach(entry -> sb.append(String.format("• %s — %s\n", + buildPreview(String.valueOf(entry.getKey()), MAX_TALLY_OPTION_CHARS), entry.getValue()))); + if (totalsMap.size() > MAX_TALLY_LINES) { + sb.append(String.format("… and %d more option(s)\n", totalsMap.size() - MAX_TALLY_LINES)); + } + } + Object valid = decision.tally().get("validBallots"); + Object participants = decision.tally().get("participants"); + if (valid instanceof Number && participants instanceof Number) { + sb.append(String.format("Ballots: %s of %s\n", valid, participants)); + } + } + + /** Slack messages are skimmed, not scrolled — a ten-option tally is noise. */ + private static final int MAX_TALLY_LINES = 6; + + /** + * Same reasoning per line: a LAST_SYNTHESIS-derived option is whatever text + * followed "Option X:", which can be a paragraph — six of those could push the + * whole decision message past Slack's per-message limit, and postSafe would + * swallow the loss. + */ + private static final int MAX_TALLY_OPTION_CHARS = 120; + + /** + * I6: a HUMAN member's turn is up. Slack is notification-only in v1 — the + * member responds through the EDDI UI/API (free-text reply capture from Slack + * is a planned follow-up); this message tells them they are up and where. + */ + @Override + public void onHumanInputRequested(GroupConversationEventSink.HumanInputRequestedEvent event) { + if (event == null || event.memberId() == null) { + return; + } + String name = event.displayName() != null && !event.displayName().isBlank() + ? event.displayName() + : event.memberId(); + String msg = String.format("🙋 *%s* — you're up in *%s*. Respond in EDDI (conversation `%s`).", + escapeMrkdwnHuman(name), escapeMrkdwnHuman(event.phaseName()), + groupConversationId != null ? groupConversationId : "unknown"); + String threadTs = expandedMode ? null : userThreadTs; + postSafe(channelId, threadTs, msg); + // A human pause is terminal for THIS listener instance — the discussion + // leg ends, and a resumed leg gets its own listener. Without the count + // down, SlackEventHandler blocks its full awaitCompletion timeout on + // every human pause (the same rule onHitlPause follows). + completionLatch.countDown(); + } + + /** + * Escapes Slack's three mrkdwn control characters — a member display name + * containing {@code } must render as text, not broadcast. + */ + private static String escapeMrkdwnHuman(String value) { + return value == null + ? "" + : value.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + @Override + public void onArtifactUpdated(GroupConversationEventSink.ArtifactUpdatedEvent event) { + // Degenerate payload → skip rather than posting noise, same as + // onDecisionReached's NONE guard. + if (event == null || event.name() == null) { + return; + } + String emoji = event.created() ? "📄" : "✏️"; + String verb = event.created() ? "created" : "updated"; + var sb = new StringBuilder(); + sb.append(String.format("%s *Artifact \"%s\"* %s (v%d)", emoji, escapeMrkdwn(event.name()), verb, event.version())); + if (event.editorAgentId() != null && !event.editorAgentId().isBlank()) { + sb.append(String.format(" by %s", escapeMrkdwn(event.editorAgentId()))); + } + if ("FINAL".equals(event.status())) { + sb.append(" — FINAL"); + } + + String threadTs = expandedMode ? null : userThreadTs; + postSafe(channelId, threadTs, sb.toString().stripTrailing()); + } + + /** + * Escapes Slack's three mrkdwn control characters. Without this, an + * LLM-authored artifact name (or an agent id) containing e.g. + * {@code } renders as a real channel broadcast. + */ + private static String escapeMrkdwn(String value) { + return value == null + ? null + : value.replace("&", "&").replace("<", "<").replace(">", ">"); + } + // ─── HITL (human-in-the-loop) ─── @Override diff --git a/src/main/java/ai/labs/eddi/modules/llm/impl/AgentOrchestrator.java b/src/main/java/ai/labs/eddi/modules/llm/impl/AgentOrchestrator.java index dd1248207b..4bf556be1c 100644 --- a/src/main/java/ai/labs/eddi/modules/llm/impl/AgentOrchestrator.java +++ b/src/main/java/ai/labs/eddi/modules/llm/impl/AgentOrchestrator.java @@ -49,6 +49,7 @@ import jakarta.enterprise.context.ApplicationScoped; import ai.labs.eddi.engine.internal.groups.LiveDiscussionRegistry; import ai.labs.eddi.configs.groups.IAgentGroupStore; +import ai.labs.eddi.configs.groups.ISharedArtifactStore; import jakarta.inject.Inject; import org.eclipse.microprofile.config.ConfigProvider; import org.jboss.logging.Logger; @@ -267,6 +268,13 @@ static void warnAboutUnenforcedBudgets(LlmConfiguration.Task task) { @Inject volatile IAgentGroupStore agentGroupStore; + /** + * I17's store, field-injected for the same reason as the two above. + * {@code ArtifactToolsProvider} treats null as "no artifact tools". + */ + @Inject + volatile ISharedArtifactStore sharedArtifactStore; + /** * Test seam for supplying the attachment services to a directly-constructed * orchestrator (CDI populates the fields above in production). Previously this @@ -644,6 +652,7 @@ ToolSetup buildToolSetup(LlmConfiguration.Task task, IConversationMemory memory) var contextual = contextualToolsProvider(); merger.addAll(List.of(builtinToolsProvider, contextual, dynamicAgentToolsProvider(), new GroupTaskToolsProvider(liveDiscussionRegistry, agentGroupStore), + new ArtifactToolsProvider(liveDiscussionRegistry, agentGroupStore, sharedArtifactStore), new AttachmentToolsProvider(contextual)), ctx); // LAZY registers every built-in's executor but shows the model only diff --git a/src/main/java/ai/labs/eddi/modules/llm/impl/ArtifactToolsProvider.java b/src/main/java/ai/labs/eddi/modules/llm/impl/ArtifactToolsProvider.java new file mode 100644 index 0000000000..e7d78b3f07 --- /dev/null +++ b/src/main/java/ai/labs/eddi/modules/llm/impl/ArtifactToolsProvider.java @@ -0,0 +1,105 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.modules.llm.impl; + +import ai.labs.eddi.configs.groups.IAgentGroupStore; +import ai.labs.eddi.configs.groups.ISharedArtifactStore; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.ArtifactConfig; +import ai.labs.eddi.engine.internal.groups.LiveDiscussionRegistry; +import ai.labs.eddi.modules.llm.tools.impl.ArtifactTools; +import ai.labs.eddi.modules.llm.tools.spi.ToolAssemblyContext; +import ai.labs.eddi.modules.llm.tools.spi.ToolContribution; +import ai.labs.eddi.modules.llm.tools.spi.ToolSourceProvider; +import org.jboss.logging.Logger; + +import java.util.List; +import java.util.Map; + +/** + * Contributes the shared-artifact tools (I17) — {@code createArtifact}, + * {@code readArtifact}, {@code proposeArtifactUpdate}, {@code listArtifacts} — + * when, and only when, the turn belongs to a live group discussion whose config + * sets {@code artifactConfig.allowArtifactTools}. + *

    + * Gate discipline is identical to {@link GroupTaskToolsProvider}, and for the + * same reasons: membership (not existence) via + * {@code LiveDiscussionRegistry#getForMember} because the group conversation id + * is a caller-supplied context variable; the member agent's own + * {@code enableBuiltInTools} switch still applies; and every uncertainty — + * missing config, unreadable group, absent store — resolves to "contribute + * nothing" (fail-closed, gate by absence). + * + * @author ginccc + */ +class ArtifactToolsProvider implements ToolSourceProvider { + + private static final Logger LOGGER = Logger.getLogger(ArtifactToolsProvider.class); + + private final LiveDiscussionRegistry liveDiscussionRegistry; + private final IAgentGroupStore groupStore; + private final ISharedArtifactStore artifactStore; + + ArtifactToolsProvider(LiveDiscussionRegistry liveDiscussionRegistry, IAgentGroupStore groupStore, + ISharedArtifactStore artifactStore) { + this.liveDiscussionRegistry = liveDiscussionRegistry; + this.groupStore = groupStore; + this.artifactStore = artifactStore; + } + + @Override + public String source() { + return "builtin"; + } + + @Override + public ToolContribution contribute(ToolAssemblyContext ctx) { + String groupConversationId = ctx.groupConversationId(); + if (groupConversationId == null || liveDiscussionRegistry == null || groupStore == null || artifactStore == null) { + return ToolContribution.empty(); + } + Boolean enableBuiltInTools = ctx.task() != null ? ctx.task().getEnableBuiltInTools() : null; + if (enableBuiltInTools == null || !enableBuiltInTools) { + return ToolContribution.empty(); + } + String callerConversationId = ctx.memory() != null ? ctx.memory().getConversationId() : null; + var live = liveDiscussionRegistry.getForMember(groupConversationId, callerConversationId); + if (live.isEmpty()) { + return ToolContribution.empty(); + } + AgentGroupConfiguration groupConfiguration = resolveGroup(live.get().getGroupId()); + ArtifactConfig config = groupConfiguration != null ? groupConfiguration.getArtifactConfig() : null; + if (config == null || !config.allowArtifactTools()) { + return ToolContribution.empty(); + } + + var tools = List.of(new ArtifactTools(liveDiscussionRegistry, groupConversationId, config, ctx.agentId(), + artifactStore)); + var reflected = ToolObjectReflector.reflect(tools); + return new ToolContribution(reflected.specs(), reflected.executors(), reflected.toolSources(), Map.of(), + List.of(), reflected.toolCanonicalNames()); + } + + /** + * The group config, or {@code null} if it cannot be read — a store failure + * withholds the write tools (fail-closed), logged so the operator sees why they + * vanished. + */ + private AgentGroupConfiguration resolveGroup(String groupId) { + if (groupId == null) { + return null; + } + try { + var resourceId = groupStore.getCurrentResourceId(groupId); + if (resourceId == null) { + return null; + } + return groupStore.read(groupId, resourceId.getVersion()); + } catch (Exception e) { + LOGGER.warnf("Could not read artifact policy for group '%s' — withholding the artifact tools: %s", groupId, e.getMessage()); + return null; + } + } +} diff --git a/src/main/java/ai/labs/eddi/modules/llm/impl/CascadeConfigValidator.java b/src/main/java/ai/labs/eddi/modules/llm/impl/CascadeConfigValidator.java index 8cb62ccd00..e937b2f236 100644 --- a/src/main/java/ai/labs/eddi/modules/llm/impl/CascadeConfigValidator.java +++ b/src/main/java/ai/labs/eddi/modules/llm/impl/CascadeConfigValidator.java @@ -17,9 +17,9 @@ import java.util.Map; /** - * Deploy-time validation for {@link ModelCascadeConfig}. Runs from - * {@code LlmTask.configure()} so misconfigurations surface at agent deployment - * rather than per-turn mid-conversation. + * Deploy-time validation for {@link ModelCascadeConfig} and the task-level + * pricing fields. Runs from {@code LlmTask.configure()} so misconfigurations + * surface at agent deployment rather than per-turn mid-conversation. *

    * Backward compatibility: conditions that older releases tolerated at * load (they still failed/degraded at runtime exactly as before — unknown @@ -53,13 +53,19 @@ static void validate(LlmConfiguration config) throws WorkflowConfigurationExcept } private static void validateTask(LlmConfiguration.Task task) throws WorkflowConfigurationException { + String taskId = task.getId() != null ? task.getId() : ""; + + // Task-level pricing is NEW (same rationale as the cascade pricing below: + // no stored config predating it can carry the field) — hard error on a + // negative value, cascade or not. + requireNonNegativeTaskPrice(taskId, "inputPricePer1M", task.getInputPricePer1M()); + requireNonNegativeTaskPrice(taskId, "outputPricePer1M", task.getOutputPricePer1M()); + ModelCascadeConfig cascade = task.getModelCascade(); if (cascade == null || !cascade.isEnabled()) { return; } - String taskId = task.getId() != null ? task.getId() : ""; - List steps = cascade.getSteps(); if (steps == null || steps.isEmpty()) { // The executor throws at runtime for empty steps (as before) — warn, don't @@ -160,6 +166,12 @@ private static void requireNonNegativePrice(String taskId, String what, Double p } } + private static void requireNonNegativeTaskPrice(String taskId, String what, Double price) throws WorkflowConfigurationException { + if (price != null && price < 0) { + throw new WorkflowConfigurationException("Invalid LLM task '" + taskId + "': " + what + " must be >= 0"); + } + } + private static void warn(String taskId, String message) { LOGGER.warnf("LLM task '%s': modelCascade — %s.", taskId, message); } diff --git a/src/main/java/ai/labs/eddi/modules/llm/impl/CascadingModelExecutor.java b/src/main/java/ai/labs/eddi/modules/llm/impl/CascadingModelExecutor.java index da17494ab9..0c548c9b73 100644 --- a/src/main/java/ai/labs/eddi/modules/llm/impl/CascadingModelExecutor.java +++ b/src/main/java/ai/labs/eddi/modules/llm/impl/CascadingModelExecutor.java @@ -717,19 +717,7 @@ static double stepToolCost(Map responseMetadata) { static double computeCost(CascadeStep step, ModelCascadeConfig cascade, Map tokenUsage) { Double inPrice = step.getInputPricePer1M() != null ? step.getInputPricePer1M() : cascade.getInputPricePer1M(); Double outPrice = step.getOutputPricePer1M() != null ? step.getOutputPricePer1M() : cascade.getOutputPricePer1M(); - if (tokenUsage == null || (inPrice == null && outPrice == null)) { - return 0.0; - } - long inputTokens = asLong(tokenUsage.get("inputTokens")); - long outputTokens = asLong(tokenUsage.get("outputTokens")); - double cost = 0.0; - if (inPrice != null) { - cost += inputTokens / 1_000_000.0 * inPrice; - } - if (outPrice != null) { - cost += outputTokens / 1_000_000.0 * outPrice; - } - return cost; + return TokenPricing.cost(inPrice, outPrice, tokenUsage); } private static long asLong(Object value) { diff --git a/src/main/java/ai/labs/eddi/modules/llm/impl/LlmTask.java b/src/main/java/ai/labs/eddi/modules/llm/impl/LlmTask.java index e13c39d71d..a703ce4275 100644 --- a/src/main/java/ai/labs/eddi/modules/llm/impl/LlmTask.java +++ b/src/main/java/ai/labs/eddi/modules/llm/impl/LlmTask.java @@ -697,7 +697,7 @@ private void executeTask(IConversationMemory memory, Task task, IWritableConvers var modelNameData = dataFactory.createData(MemoryKeys.AUDIT_MODEL_NAME, modelName); currentStep.storeData(modelNameData); - accumulateAuditEvidence(currentStep, responseMetadata, toolTrace, task.getId()); + accumulateAuditEvidence(currentStep, responseMetadata, toolTrace, task); } // Store tool trace if available @@ -785,19 +785,26 @@ private void executeTask(IConversationMemory memory, Task task, IWritableConvers * plus every escalated cascade step and tool-loop iteration inside each. The * ledger has to report the turn's total, but {@code getLatestData} is * last-write-wins, so each contributor read-modify-writes rather than - * overwriting. Cost is the sum of the two dollar signals that actually exist: - * configured cascade LLM pricing and tracked tool cost. There is no token price - * table for non-cascade tasks, so those contribute tool cost only. + * overwriting. Cost sums the LLM token spend and the tracked tool cost. A + * cascade run prices its own steps and reports the total as + * {@code cascadeCostUsd}; a plain call is priced here from the task-level + * {@code inputPricePer1M}/{@code outputPricePer1M} — the key presence + * discriminates, so cascade tokens are never priced twice. Unpriced configs + * contribute $0 exactly as before. */ private void accumulateAuditEvidence(IWritableConversationStep currentStep, Map responseMetadata, - List> toolTrace, String llmTaskId) { + List> toolTrace, Task task) { if (responseMetadata != null) { - if (responseMetadata.get("tokenUsage") instanceof Map tokenUsage) { + Map tokenUsage = responseMetadata.get("tokenUsage") instanceof Map tu ? tu : null; + if (tokenUsage != null) { accumulateTokenUsage(currentStep, tokenUsage); } - accumulateCost(currentStep, asDouble(responseMetadata.get("cascadeCostUsd")) + asDouble(responseMetadata.get("toolCostUsd"))); + double llmCostUsd = responseMetadata.containsKey("cascadeCostUsd") + ? asDouble(responseMetadata.get("cascadeCostUsd")) + : TokenPricing.cost(task.getInputPricePer1M(), task.getOutputPricePer1M(), tokenUsage); + accumulateCost(currentStep, llmCostUsd + asDouble(responseMetadata.get("toolCostUsd"))); } - accumulateToolCalls(currentStep, toolTrace, llmTaskId); + accumulateToolCalls(currentStep, toolTrace, task.getId()); } private void accumulateTokenUsage(IWritableConversationStep currentStep, Map delta) { @@ -847,7 +854,10 @@ private void accumulateToolCalls(IWritableConversationStep currentStep, List 0.0), not delta <= 0.0: NaN fails every comparison, so the + // latter admits it — and one NaN poisons the running total forever, + // silently disabling every dollar ceiling that compares against it. + if (!Double.isFinite(delta) || !(delta > 0.0)) { return; } double total = delta; @@ -1087,7 +1097,7 @@ private void executeResume(IConversationMemory memory, LlmConfiguration llmConfi // Known gap: the continuation's tokenUsage covers only the post-resume model // calls (see the comment above) — the pre-pause segment is not recoverable // here, so a paused turn's ledger entry under-reports by that segment. - accumulateAuditEvidence(currentStep, responseMetadata, toolTrace, task.getId()); + accumulateAuditEvidence(currentStep, responseMetadata, toolTrace, task); } // Tool trace (mirror executeTask) diff --git a/src/main/java/ai/labs/eddi/modules/llm/impl/TokenPricing.java b/src/main/java/ai/labs/eddi/modules/llm/impl/TokenPricing.java new file mode 100644 index 0000000000..ffd1efe627 --- /dev/null +++ b/src/main/java/ai/labs/eddi/modules/llm/impl/TokenPricing.java @@ -0,0 +1,57 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.modules.llm.impl; + +import java.util.Map; + +/** + * The single home of the configured-price token-cost arithmetic. Both dollar + * signals that price LLM token spend — cascade steps + * ({@code CascadingModelExecutor.computeCost}) and plain task-level pricing + * ({@code LlmTask.accumulateAuditEvidence}) — delegate here, so the formula + * cannot drift between the two paths. + *

    + * There is deliberately no built-in provider price table: prices are an + * agent-designer concern and belong in configuration, where they can track + * provider changes without a code release. + *

    + * Public because the group transcript summarizer (I9, + * {@code GroupContextBuilder}) prices its own summarization calls with the same + * formula. + */ +public final class TokenPricing { + + private TokenPricing() { + } + + /** + * Dollar cost of one call from its reported token usage and configured + * per-1M-token prices. A null price means "unpriced" and contributes 0; with + * both prices null (or no usage reported) the call is free. + * + * @param tokenUsage + * the {@code tokenUsage} metadata map carrying + * {@code inputTokens}/{@code outputTokens} as numbers + */ + public static double cost(Double inputPricePer1M, Double outputPricePer1M, Map tokenUsage) { + if (tokenUsage == null || (inputPricePer1M == null && outputPricePer1M == null)) { + return 0.0; + } + long inputTokens = asLong(tokenUsage.get("inputTokens")); + long outputTokens = asLong(tokenUsage.get("outputTokens")); + double cost = 0.0; + if (inputPricePer1M != null) { + cost += inputTokens / 1_000_000.0 * inputPricePer1M; + } + if (outputPricePer1M != null) { + cost += outputTokens / 1_000_000.0 * outputPricePer1M; + } + return cost; + } + + private static long asLong(Object value) { + return value instanceof Number n ? n.longValue() : 0L; + } +} diff --git a/src/main/java/ai/labs/eddi/modules/llm/model/LlmConfiguration.java b/src/main/java/ai/labs/eddi/modules/llm/model/LlmConfiguration.java index 2f1548c7db..356f7ba229 100644 --- a/src/main/java/ai/labs/eddi/modules/llm/model/LlmConfiguration.java +++ b/src/main/java/ai/labs/eddi/modules/llm/model/LlmConfiguration.java @@ -314,6 +314,21 @@ public static class Task { */ private Map toolPricing; + /** + * Input price per 1M tokens in USD for this task's model calls. Null = + * unpriced, contributing $0 to the tracked conversation cost. Applies to + * non-cascade calls; a cascade run prices its steps via {@code modelCascade}'s + * own {@code inputPricePer1M}/{@code outputPricePer1M} (steps may target + * different models, so task-level prices are not inherited). + */ + private Double inputPricePer1M; + + /** + * Output price per 1M tokens in USD. Same semantics as + * {@link #inputPricePer1M}. + */ + private Double outputPricePer1M; + /** Enable cost tracking */ private Boolean enableCostTracking = true; @@ -775,6 +790,22 @@ public void setToolPricing(Map toolPricing) { this.toolPricing = toolPricing; } + public Double getInputPricePer1M() { + return inputPricePer1M; + } + + public void setInputPricePer1M(Double inputPricePer1M) { + this.inputPricePer1M = inputPricePer1M; + } + + public Double getOutputPricePer1M() { + return outputPricePer1M; + } + + public void setOutputPricePer1M(Double outputPricePer1M) { + this.outputPricePer1M = outputPricePer1M; + } + public Boolean getEnableCostTracking() { return enableCostTracking; } diff --git a/src/main/java/ai/labs/eddi/modules/llm/tools/impl/ArtifactTools.java b/src/main/java/ai/labs/eddi/modules/llm/tools/impl/ArtifactTools.java new file mode 100644 index 0000000000..fade277319 --- /dev/null +++ b/src/main/java/ai/labs/eddi/modules/llm/tools/impl/ArtifactTools.java @@ -0,0 +1,312 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.modules.llm.tools.impl; + +import ai.labs.eddi.configs.groups.ArtifactValidators; +import ai.labs.eddi.configs.groups.ISharedArtifactStore; +import ai.labs.eddi.configs.groups.ISharedArtifactStore.ArtifactGoneException; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.ArtifactConfig; +import ai.labs.eddi.configs.groups.model.GroupConversation; +import ai.labs.eddi.configs.groups.model.GroupConversation.ArtifactChange; +import ai.labs.eddi.configs.groups.model.SharedArtifact; +import ai.labs.eddi.configs.groups.model.SharedArtifact.ArtifactStatus; +import ai.labs.eddi.configs.groups.model.SharedArtifact.ArtifactType; +import ai.labs.eddi.datastore.IResourceStore; +import ai.labs.eddi.engine.internal.groups.LiveDiscussionRegistry; +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; +import jakarta.enterprise.inject.Vetoed; +import org.jboss.logging.Logger; + +import java.time.Instant; +import java.util.List; +import java.util.Locale; + +/** + * Lets group members create and co-edit shared artifacts (I17, blackboard-lite) + * — typed documents that live outside the dialogue, so structured work + * stops being prose the next agent re-parses. + *

    + * Writes go through the artifact store directly. Artifacts have their + * own collection, so the F1 rule that task-list writes must mutate the live + * {@link GroupConversation} instance (or be clobbered by the loop's next + * whole-document persist) does not apply. The live registry is still consulted + * — a write against a finished or paused discussion is refused, and the + * creation/edit is announced through the live instance's artifact-change queue + * so the discussion loop can fire {@code artifact_updated}. + *

    + * Concurrency is deterministic CAS-and-retry, not an LLM merge. Every + * update presents the version it read; a stale version gets a "re-read and + * merge" sentence and the model retries against fresh content. Creation + * (duplicate-name + count cap) is a check-then-act, so it synchronizes on the + * live discussion instance — PARALLEL phases genuinely run members at once. + *

    + * Constructed per-turn with runtime values — NOT a CDI bean. {@code @Vetoed} is + * load-bearing for the same deployment-failure reason as + * {@link GroupTaskTools}. + * + * @author ginccc + */ +@Vetoed +public class ArtifactTools { + + private static final Logger LOGGER = Logger.getLogger(ArtifactTools.class); + + static final int MAX_NAME_LENGTH = 200; + + private final LiveDiscussionRegistry registry; + private final String groupConversationId; + private final ArtifactConfig config; + private final String agentId; + private final ISharedArtifactStore artifactStore; + + public ArtifactTools(LiveDiscussionRegistry registry, String groupConversationId, ArtifactConfig config, String agentId, + ISharedArtifactStore artifactStore) { + this.registry = registry; + this.groupConversationId = groupConversationId; + this.config = config; + this.agentId = agentId; + this.artifactStore = artifactStore; + } + + @Tool("Create a new shared artifact — a named document the whole team can read and propose changes to. " + + "Use listArtifacts first to avoid duplicating one that already exists.") + public String createArtifact( + @P("Short unique name, how the team refers to this artifact") String name, + @P("Content type: TEXT, MARKDOWN or JSON") String type, + @P("The initial content") String content) { + + GroupConversation gc = liveDiscussion(); + if (gc == null) { + return "This discussion is no longer accepting artifact changes (it has finished or is paused)."; + } + if (name == null || name.isBlank()) { + return "An artifact needs a name."; + } + String trimmedName = name.trim(); + if (trimmedName.length() > MAX_NAME_LENGTH) { + return "The name is too long (%d chars, max %d). Put the detail in the content.".formatted(trimmedName.length(), MAX_NAME_LENGTH); + } + ArtifactType artifactType = parseType(type); + if (artifactType == null) { + return "Unknown artifact type \"%s\". Use TEXT, MARKDOWN or JSON.".formatted(type); + } + String rejection = checkContent(content); + if (rejection != null) { + return rejection; + } + + // Check-then-act (duplicate name + count cap) under one monitor: the live + // instance is the one object every member of this discussion shares in + // this JVM, so it is the natural lock for creation races. Updates need no + // lock — the store's version CAS decides those. + synchronized (gc) { + List existing; + try { + existing = artifactStore.listByGroupConversationId(groupConversationId); + } catch (IResourceStore.ResourceStoreException e) { + LOGGER.warnf("Could not list artifacts for %s — refusing the create: %s", groupConversationId, e.getMessage()); + return "The artifact store is unavailable right now; try again next turn."; + } + if (existing.stream().anyMatch(a -> trimmedName.equalsIgnoreCase(a.getName()))) { + return "An artifact named \"%s\" already exists. Read it with readArtifact and use proposeArtifactUpdate to change it." + .formatted(trimmedName); + } + if (existing.size() >= config.maxArtifactsPerDiscussion()) { + return "This discussion already has %d artifacts, which is the limit. Update an existing one instead of creating more." + .formatted(existing.size()); + } + + var artifact = new SharedArtifact(); + artifact.setGroupConversationId(groupConversationId); + artifact.setOwnerUserId(gc.getUserId()); + artifact.setName(trimmedName); + artifact.setType(artifactType); + artifact.setContent(content); + artifact.setVersion(1); + artifact.setLastEditorAgentId(agentId); + artifact.setStatus(ArtifactStatus.DRAFT); + artifact.setCreatedAt(Instant.now()); + artifact.setUpdatedAt(artifact.getCreatedAt()); + try { + artifactStore.create(artifact); + } catch (IResourceStore.ResourceStoreException e) { + LOGGER.warnf("Could not create artifact '%s' for %s: %s", trimmedName, groupConversationId, e.getMessage()); + return "The artifact store is unavailable right now; try again next turn."; + } + + gc.queueArtifactChange(new ArtifactChange(artifact.getId(), trimmedName, artifactType.name(), 1, agentId, + ArtifactStatus.DRAFT.name(), true)); + LOGGER.infof("Agent '%s' created artifact '%s' (v1) on group conversation %s", agentId, trimmedName, groupConversationId); + return "Created artifact \"%s\" (v1). The team can read it with readArtifact and change it with proposeArtifactUpdate." + .formatted(trimmedName); + } + } + + @Tool("Read a shared artifact's current content and version. You need the version to propose an update.") + public String readArtifact(@P("The artifact's name (or id)") String nameOrId) { + SharedArtifact artifact = resolve(nameOrId); + if (artifact == null) { + return "No artifact named \"%s\" here. Use listArtifacts to see what exists.".formatted(nameOrId != null ? nameOrId.trim() : ""); + } + return "Artifact \"%s\" (%s, %s, v%d, last edited by %s):\n%s".formatted( + artifact.getName(), artifact.getType(), artifact.getStatus(), artifact.getVersion(), + artifact.getLastEditorAgentId() != null ? artifact.getLastEditorAgentId() : "unknown", + artifact.getContent() != null ? artifact.getContent() : ""); + } + + @Tool("Propose new content for a shared artifact. You must pass the version you READ — if someone changed the " + + "artifact since, your update is rejected and you re-read, merge your change into theirs, and retry.") + public String proposeArtifactUpdate( + @P("The artifact's name (or id)") String nameOrId, + @P("The complete new content (it replaces the old content)") String content, + @P("The version you read, from readArtifact") long expectedVersion, + @P(value = "Pass true to freeze the artifact as FINAL after this update. " + + "A FINAL artifact accepts no further updates.", + required = false) Boolean markFinal) { + + GroupConversation gc = liveDiscussion(); + if (gc == null) { + return "This discussion is no longer accepting artifact changes (it has finished or is paused)."; + } + SharedArtifact artifact = resolve(nameOrId); + if (artifact == null) { + return "No artifact named \"%s\" here. Use listArtifacts to see what exists.".formatted(nameOrId != null ? nameOrId.trim() : ""); + } + if (artifact.getStatus() == ArtifactStatus.FINAL) { + return "Artifact \"%s\" is FINAL and accepts no further updates.".formatted(artifact.getName()); + } + String rejection = checkContent(content); + if (rejection != null) { + return rejection; + } + if (artifact.getVersion() != expectedVersion) { + return staleVersion(artifact.getName(), artifact.getVersion()); + } + + artifact.applyEdit(content, agentId, Instant.now()); + if (Boolean.TRUE.equals(markFinal)) { + artifact.setStatus(ArtifactStatus.FINAL); + } + try { + artifactStore.updateIfVersion(artifact, expectedVersion); + } catch (IResourceStore.ResourceModifiedException e) { + // Lost the race after our read — report the CURRENT version so the + // retry is usable. A failed re-read still yields the retry instruction. + long nowVersion = currentVersionOf(artifact.getId()); + return nowVersion > 0 + ? staleVersion(artifact.getName(), nowVersion) + : "Artifact \"%s\" changed since you read it; re-read and merge your change.".formatted(artifact.getName()); + } catch (ArtifactGoneException e) { + return "Artifact \"%s\" no longer exists.".formatted(artifact.getName()); + } catch (IResourceStore.ResourceStoreException e) { + LOGGER.warnf("Could not update artifact '%s' for %s: %s", artifact.getName(), groupConversationId, e.getMessage()); + return "The artifact store is unavailable right now; try again next turn."; + } + + gc.queueArtifactChange(new ArtifactChange(artifact.getId(), artifact.getName(), artifact.getType().name(), + artifact.getVersion(), agentId, artifact.getStatus().name(), false)); + LOGGER.infof("Agent '%s' updated artifact '%s' to v%d on group conversation %s", + agentId, artifact.getName(), artifact.getVersion(), groupConversationId); + return "Updated \"%s\" to v%d.%s".formatted(artifact.getName(), artifact.getVersion(), + artifact.getStatus() == ArtifactStatus.FINAL ? " It is now FINAL." : ""); + } + + @Tool("List this discussion's shared artifacts with their type, status and current version.") + public String listArtifacts() { + List artifacts; + try { + artifacts = artifactStore.listByGroupConversationId(groupConversationId); + } catch (IResourceStore.ResourceStoreException e) { + return "The artifact store is unavailable right now; try again next turn."; + } + if (artifacts.isEmpty()) { + return "No artifacts yet. Create one with createArtifact."; + } + var sb = new StringBuilder("Shared artifacts:\n"); + for (SharedArtifact a : artifacts) { + sb.append("- \"").append(a.getName()).append("\" (").append(a.getType()).append(", ").append(a.getStatus()) + .append(", v").append(a.getVersion()).append(')'); + if (a.getLastEditorAgentId() != null) { + sb.append(" — last edited by ").append(a.getLastEditorAgentId()); + } + sb.append('\n'); + } + return sb.toString().stripTrailing(); + } + + /** The plan-specified stale-CAS sentence, verbatim shape. */ + private static String staleVersion(String name, long currentVersion) { + return "Artifact \"%s\" changed since you read it (now v%d); re-read and merge your change.".formatted(name, currentVersion); + } + + /** + * Resolves an artifact by name or id — but only among THIS discussion's + * artifacts. Never a raw store read of a caller-supplied id: the id is + * model-controlled text, and resolving it globally would read another + * discussion's artifact. + */ + private SharedArtifact resolve(String nameOrId) { + if (nameOrId == null || nameOrId.isBlank()) { + return null; + } + String wanted = nameOrId.trim(); + List artifacts; + try { + artifacts = artifactStore.listByGroupConversationId(groupConversationId); + } catch (IResourceStore.ResourceStoreException e) { + LOGGER.warnf("Could not resolve artifact '%s' for %s: %s", wanted, groupConversationId, e.getMessage()); + return null; + } + return artifacts.stream() + .filter(a -> wanted.equalsIgnoreCase(a.getName()) || wanted.equals(a.getId())) + .findFirst().orElse(null); + } + + /** Size cap first (cheap), then the config's declarative validator chain. */ + private String checkContent(String content) { + if (content == null || content.isBlank()) { + return "An artifact needs content."; + } + int bytes = SharedArtifact.contentBytes(content); + if (bytes > SharedArtifact.MAX_CONTENT_BYTES) { + // Ceil, not truncate: MAX+1 bytes must not read "256 KB is over the + // 256 KB limit" — the model retries at the same size on that sentence. + return "The content is %d KB, over the %d KB limit for a single artifact. Split it or shorten it." + .formatted(Math.ceilDiv(bytes, 1024), SharedArtifact.MAX_CONTENT_BYTES / 1024); + } + return ArtifactValidators.firstRejection(config.validators(), content); + } + + private static ArtifactType parseType(String type) { + if (type == null || type.isBlank()) { + return null; + } + try { + return ArtifactType.valueOf(type.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + return null; + } + } + + /** + * Live-instance lookup for writes. Authorization happened at assembly time (the + * provider resolves membership via {@code getForMember}); this lookup only + * answers "is the discussion still running", so a paused or finished discussion + * refuses instead of accepting a write nobody will announce. + */ + private GroupConversation liveDiscussion() { + return registry.get(groupConversationId).orElse(null); + } + + /** The stored artifact's current version, or 0 when unreadable. */ + private long currentVersionOf(String artifactId) { + try { + return artifactStore.read(artifactId).getVersion(); + } catch (Exception e) { + return 0; + } + } +} diff --git a/src/main/resources/group-templates/decision-board.json b/src/main/resources/group-templates/decision-board.json new file mode 100644 index 0000000000..52e3b0b9f5 --- /dev/null +++ b/src/main/resources/group-templates/decision-board.json @@ -0,0 +1,85 @@ +{ + "manifest": { + "templateId": "decision-board", + "title": "Decision Board", + "description": "A deliberation board with a human director as a full voting member: positions are stated blind, deliberated openly, distilled into explicit options, and decided by ballot — cast in structural independence, with the losing side's statements recorded as dissents. The human's ballot pauses the discussion until they submit (or the turn times out). Ties go to the chair; HUMAN_DECIDES tie-breaking remains save-time rejected until its resume machinery ships.", + "requiredRoles": [ + { "role": "advisor1", "description": "First board advisor" }, + { "role": "advisor2", "description": "Second board advisor" }, + { "role": "humanDirector", "description": "Human board member — their principal id; votes like any member, the discussion waits for their ballot" }, + { "role": "chair", "description": "Chairs the board: distills options, synthesizes, breaks ties" } + ] + }, + "config": { + "name": "Decision Board", + "description": "Hybrid human/agent deliberation ending in an auditable ballot", + "style": "CUSTOM", + "moderatorAgentId": "$chair", + "members": [ + { "agentId": "$advisor1", "displayName": "Advisor 1", "speakingOrder": 1 }, + { "agentId": "$advisor2", "displayName": "Advisor 2", "speakingOrder": 2 }, + { "agentId": "$humanDirector", "displayName": "Human Director", "speakingOrder": 3, "memberType": "HUMAN" } + ], + "humanMemberConfig": { "turnTimeout": "PT24H", "onTimeout": "SKIP_TURN" }, + "phases": [ + { + "name": "Positions", + "type": "OPINION", + "participants": "ALL", + "turnOrder": "PARALLEL", + "contextScope": "NONE", + "repeats": 1 + }, + { + "name": "Deliberation", + "type": "OPINION", + "participants": "ALL", + "turnOrder": "SEQUENTIAL", + "contextScope": "FULL", + "repeats": 2, + "allowAbstention": true + }, + { + "name": "Options", + "type": "SYNTHESIS", + "participants": "MODERATOR", + "turnOrder": "SEQUENTIAL", + "contextScope": "FULL", + "inputTemplate": "Distill the deliberation on:\n\"{question}\"\n\ninto 2-4 concrete, mutually exclusive options the board can vote on. Format each on its own line as:\nOption A: \nOption B: \n(and so on). State each option's essential trade-off in one sentence beneath it.", + "repeats": 1 + }, + { + "name": "Ballot", + "type": "VOTE", + "participants": "ALL", + "turnOrder": "PARALLEL", + "contextScope": "NONE", + "repeats": 1, + "voteConfig": { + "method": "MAJORITY", + "optionsSource": "LAST_SYNTHESIS", + "quorum": 0.5, + "tiePolicy": "MODERATOR_DECIDES" + } + }, + { + "name": "Resolution", + "type": "SYNTHESIS", + "participants": "MODERATOR", + "turnOrder": "SEQUENTIAL", + "contextScope": "FULL", + "repeats": 1 + } + ], + "recordDissents": true, + "protocol": { + "agentTimeoutSeconds": 180, + "onAgentFailure": "SKIP", + "maxRetries": 2, + "onMemberUnavailable": "SKIP", + "maxTurns": 40, + "maxCostPerDiscussion": 5.0, + "onCostExceeded": "SYNTHESIZE_NOW" + } + } +} diff --git a/src/main/resources/group-templates/editorial-team.json b/src/main/resources/group-templates/editorial-team.json new file mode 100644 index 0000000000..351703c7aa --- /dev/null +++ b/src/main/resources/group-templates/editorial-team.json @@ -0,0 +1,70 @@ +{ + "manifest": { + "templateId": "editorial-team", + "title": "Editorial Team", + "description": "A writer drafts a shared artifact, editors critique and the writer revises, dissents are recorded against the final synthesis, and the deliverable lives as a versioned shared document (CAS-guarded, validator-bounded) rather than prose scattered through the transcript.", + "requiredRoles": [ + { "role": "writer", "description": "Drafts and revises the shared document" }, + { "role": "editor1", "description": "First editor — critiques each draft" }, + { "role": "editor2", "description": "Second editor — critiques each draft" }, + { "role": "moderator", "description": "Synthesizes, quoting the final artifact" } + ] + }, + "config": { + "name": "Editorial Team", + "description": "Peer-review authoring around a shared artifact with recorded dissents", + "style": "CUSTOM", + "moderatorAgentId": "$moderator", + "members": [ + { "agentId": "$writer", "displayName": "Writer", "speakingOrder": 1, "role": "WRITER" }, + { "agentId": "$editor1", "displayName": "Editor 1", "speakingOrder": 2, "role": "EDITOR" }, + { "agentId": "$editor2", "displayName": "Editor 2", "speakingOrder": 3, "role": "EDITOR" } + ], + "phases": [ + { + "name": "Draft", + "type": "OPINION", + "participants": "ROLE:WRITER", + "turnOrder": "SEQUENTIAL", + "contextScope": "FULL", + "inputTemplate": "Draft the requested document for:\n\"{question}\"\n\nCreate it as a shared artifact (createArtifact) so the editors can propose changes against a versioned copy, then summarize what you drafted.", + "repeats": 1 + }, + { + "name": "Editorial Review", + "type": "OPINION", + "participants": "ROLE:EDITOR", + "turnOrder": "SEQUENTIAL", + "contextScope": "FULL", + "inputTemplate": "Review the shared artifact for:\n\"{question}\"\n\nRead it (readArtifact), then either propose a concrete improvement (proposeArtifactUpdate, re-reading first if your version is stale) or state precisely why it is ready as-is.", + "repeats": 2, + "allowAbstention": true + }, + { + "name": "Final Synthesis", + "type": "SYNTHESIS", + "participants": "MODERATOR", + "turnOrder": "SEQUENTIAL", + "contextScope": "FULL", + "repeats": 1 + } + ], + "recordDissents": true, + "artifactConfig": { + "allowArtifactTools": true, + "maxArtifactsPerDiscussion": 3, + "validators": [ + { "kind": "MAX_LENGTH", "spec": "50000" } + ] + }, + "protocol": { + "agentTimeoutSeconds": 180, + "onAgentFailure": "SKIP", + "maxRetries": 2, + "onMemberUnavailable": "SKIP", + "maxTurns": 40, + "maxCostPerDiscussion": 5.0, + "onCostExceeded": "SYNTHESIZE_NOW" + } + } +} diff --git a/src/main/resources/group-templates/index.txt b/src/main/resources/group-templates/index.txt new file mode 100644 index 0000000000..32908fd9c5 --- /dev/null +++ b/src/main/resources/group-templates/index.txt @@ -0,0 +1,5 @@ +research-pod.json +editorial-team.json +ops-task-force.json +decision-board.json +negotiation-table.json diff --git a/src/main/resources/group-templates/negotiation-table.json b/src/main/resources/group-templates/negotiation-table.json new file mode 100644 index 0000000000..b2e34cbeda --- /dev/null +++ b/src/main/resources/group-templates/negotiation-table.json @@ -0,0 +1,32 @@ +{ + "manifest": { + "templateId": "negotiation-table", + "title": "Negotiation Table", + "description": "Two parties negotiate under the typed bargaining protocol: stated interests, opening proposals, bargaining rounds against a quoted concession ledger, arbitration only if no unanimous agreement is signed, and a final synthesis quoting the ledger. The arbiter must be a deployed AGENT — it moderates the arbitration and synthesis phases. For a human decision-maker in the loop, use the decision-board template (HUMAN roster members) or add hitlConfig approval gates to the instantiated group instead.", + "requiredRoles": [ + { "role": "partyA", "description": "First negotiating party" }, + { "role": "partyB", "description": "Second negotiating party" }, + { "role": "arbiter", "description": "Arbitrates if bargaining fails; synthesizes the outcome (must be a deployed agent)" } + ] + }, + "config": { + "name": "Negotiation Table", + "description": "Typed two-party bargaining with a concession ledger and arbitration fallback", + "style": "NEGOTIATION", + "maxRounds": 4, + "moderatorAgentId": "$arbiter", + "members": [ + { "agentId": "$partyA", "displayName": "Party A", "speakingOrder": 1 }, + { "agentId": "$partyB", "displayName": "Party B", "speakingOrder": 2 } + ], + "protocol": { + "agentTimeoutSeconds": 180, + "onAgentFailure": "SKIP", + "maxRetries": 2, + "onMemberUnavailable": "SKIP", + "maxTurns": 40, + "maxCostPerDiscussion": 5.0, + "onCostExceeded": "SYNTHESIZE_NOW" + } + } +} diff --git a/src/main/resources/group-templates/ops-task-force.json b/src/main/resources/group-templates/ops-task-force.json new file mode 100644 index 0000000000..a3d4691b8d --- /dev/null +++ b/src/main/resources/group-templates/ops-task-force.json @@ -0,0 +1,44 @@ +{ + "manifest": { + "templateId": "ops-task-force", + "title": "Ops Task Force", + "description": "Plan → execute → verify with the planner decomposing the goal, bid-based task assignment (members bid blind, highest self-assessed confidence wins), agent-filed follow-up tasks, runtime recruitment of specialists, and a dollar ceiling per run.", + "requiredRoles": [ + { "role": "operator1", "description": "First executing member" }, + { "role": "operator2", "description": "Second executing member" }, + { "role": "operator3", "description": "Third executing member" }, + { "role": "moderator", "description": "Plans the task breakdown, verifies results, synthesizes" } + ] + }, + "config": { + "name": "Ops Task Force", + "description": "Task-force operations with bid assignment, agent-filed tasks and recruitment", + "style": "TASK_FORCE", + "moderatorAgentId": "$moderator", + "members": [ + { "agentId": "$operator1", "displayName": "Operator 1", "speakingOrder": 1 }, + { "agentId": "$operator2", "displayName": "Operator 2", "speakingOrder": 2 }, + { "agentId": "$operator3", "displayName": "Operator 3", "speakingOrder": 3 } + ], + "taskListConfig": { + "allowAgentTaskCreation": true, + "maxAgentAddedTasksPerDiscussion": 20, + "maxPerTurn": 3, + "assignmentMode": "BID" + }, + "dynamicAgents": { + "enabled": true, + "allowRecruitment": true, + "maxRecruitedAgentsPerDiscussion": 3 + }, + "protocol": { + "agentTimeoutSeconds": 180, + "onAgentFailure": "SKIP", + "maxRetries": 2, + "onMemberUnavailable": "SKIP", + "maxTurns": 80, + "maxCostPerDiscussion": 10.0, + "onCostExceeded": "SYNTHESIZE_NOW" + } + } +} diff --git a/src/main/resources/group-templates/research-pod.json b/src/main/resources/group-templates/research-pod.json new file mode 100644 index 0000000000..d142bd93f8 --- /dev/null +++ b/src/main/resources/group-templates/research-pod.json @@ -0,0 +1,71 @@ +{ + "manifest": { + "templateId": "research-pod", + "title": "Research Pod", + "description": "Delphi-style independent estimation with anonymous convergence rounds. Convergence detection ends rounds early once positions stabilize, a retro harvests lessons into team memory, transcript windowing keeps long runs affordable, and a dollar ceiling bounds each discussion.", + "requiredRoles": [ + { "role": "researcher1", "description": "First independent researcher" }, + { "role": "researcher2", "description": "Second independent researcher" }, + { "role": "researcher3", "description": "Third independent researcher" }, + { "role": "moderator", "description": "Synthesizes the pod's findings and judges convergence" } + ] + }, + "config": { + "name": "Research Pod", + "description": "Delphi-style independent estimation with convergence, retro memory and cost bounds", + "style": "CUSTOM", + "moderatorAgentId": "$moderator", + "members": [ + { "agentId": "$researcher1", "displayName": "Researcher 1", "speakingOrder": 1 }, + { "agentId": "$researcher2", "displayName": "Researcher 2", "speakingOrder": 2 }, + { "agentId": "$researcher3", "displayName": "Researcher 3", "speakingOrder": 3 } + ], + "phases": [ + { + "name": "Independent Estimates", + "type": "OPINION", + "participants": "ALL", + "turnOrder": "PARALLEL", + "contextScope": "NONE", + "repeats": 1 + }, + { + "name": "Anonymous Revision", + "type": "OPINION", + "participants": "ALL", + "turnOrder": "PARALLEL", + "contextScope": "ANONYMOUS", + "repeats": 3, + "allowAbstention": true, + "convergence": { "enabled": true, "minRepeats": 2, "threshold": 0.8 } + }, + { + "name": "Synthesis", + "type": "SYNTHESIS", + "participants": "MODERATOR", + "turnOrder": "SEQUENTIAL", + "contextScope": "FULL", + "repeats": 1 + }, + { + "name": "Retro", + "type": "RETRO", + "participants": "MODERATOR", + "turnOrder": "SEQUENTIAL", + "contextScope": "FULL", + "repeats": 1 + } + ], + "retroConfig": { "maxLessonsPerRun": 3, "maxStoredLessons": 50 }, + "contextWindow": { "enabled": true, "maxRecentEntries": 30, "summarizeOverflow": false }, + "protocol": { + "agentTimeoutSeconds": 180, + "onAgentFailure": "SKIP", + "maxRetries": 2, + "onMemberUnavailable": "SKIP", + "maxTurns": 60, + "maxCostPerDiscussion": 5.0, + "onCostExceeded": "SYNTHESIZE_NOW" + } + } +} diff --git a/src/test/java/ai/labs/eddi/configs/groups/ArtifactValidatorsTest.java b/src/test/java/ai/labs/eddi/configs/groups/ArtifactValidatorsTest.java new file mode 100644 index 0000000000..bf94e43ba8 --- /dev/null +++ b/src/test/java/ai/labs/eddi/configs/groups/ArtifactValidatorsTest.java @@ -0,0 +1,189 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.configs.groups; + +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.ArtifactConfig; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.ArtifactValidator; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.ValidatorKind; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * I17 — the declarative artifact validation chain: save-time spec checks fail + * the config save with an actionable path, write-time failures come back as + * rejection sentences for the model, and a broken spec fails closed. + * + * @author tests + */ +class ArtifactValidatorsTest { + + private static final String SCHEMA = """ + {"type":"object","required":["title"],"properties":{"title":{"type":"string"}}}"""; + + private static ArtifactConfig config(ArtifactValidator... validators) { + return new ArtifactConfig(true, 5, List.of(validators)); + } + + // ================================================================= + // save-time: requireValidSpecs + // ================================================================= + + @Test + @DisplayName("valid specs of all three kinds pass the save-time check") + void validSpecs_pass() { + assertDoesNotThrow(() -> ArtifactValidators.requireValidSpecs(config( + new ArtifactValidator(ValidatorKind.JSON_SCHEMA, SCHEMA), + new ArtifactValidator(ValidatorKind.REGEX, "^#"), + new ArtifactValidator(ValidatorKind.MAX_LENGTH, "1000")))); + } + + @Test + @DisplayName("null config or empty chain is a no-op") + void absentConfig_noOp() { + assertDoesNotThrow(() -> ArtifactValidators.requireValidSpecs(null)); + assertDoesNotThrow(() -> ArtifactValidators.requireValidSpecs(new ArtifactConfig(true, 5, null))); + } + + @Test + @DisplayName("a broken spec fails the save and names the validator's position") + void brokenSpecs_throwWithPath() { + var badRegex = assertThrows(IllegalArgumentException.class, + () -> ArtifactValidators.requireValidSpecs(config(new ArtifactValidator(ValidatorKind.REGEX, "[unclosed")))); + assertTrue(badRegex.getMessage().contains("validators[0]"), badRegex.getMessage()); + + var badLength = assertThrows(IllegalArgumentException.class, + () -> ArtifactValidators.requireValidSpecs(config( + new ArtifactValidator(ValidatorKind.REGEX, "ok"), + new ArtifactValidator(ValidatorKind.MAX_LENGTH, "lots")))); + assertTrue(badLength.getMessage().contains("validators[1]"), badLength.getMessage()); + + assertThrows(IllegalArgumentException.class, + () -> ArtifactValidators.requireValidSpecs(config(new ArtifactValidator(ValidatorKind.MAX_LENGTH, "0")))); + assertThrows(IllegalArgumentException.class, + () -> ArtifactValidators.requireValidSpecs(config(new ArtifactValidator(null, "x")))); + assertThrows(IllegalArgumentException.class, + () -> ArtifactValidators.requireValidSpecs(config(new ArtifactValidator(ValidatorKind.JSON_SCHEMA, " ")))); + } + + @Test + @DisplayName("a schema with an invalid keyword VALUE fails the save — parse alone would admit it") + void invalidKeywordValue_failsMetaSchema() { + // {"type":"strng"} is perfectly valid JSON and getSchema() parses it; + // only meta-schema validation catches the typo'd simple type. + var e = assertThrows(IllegalArgumentException.class, + () -> ArtifactValidators.requireValidSpecs(config( + new ArtifactValidator(ValidatorKind.JSON_SCHEMA, "{\"type\":\"strng\"}")))); + assertTrue(e.getMessage().contains("validators[0]"), e.getMessage()); + } + + @Test + @DisplayName("a null validator ENTRY fails the save with the positional message, not an NPE") + void nullValidatorEntry_actionableNotNpe() { + // Arrays.asList permits the null element; the config copy must too + // (List.copyOf would NPE during deserialization, preempting this message). + var config = new ArtifactConfig(true, 5, Arrays.asList( + new ArtifactValidator(ValidatorKind.MAX_LENGTH, "10"), null)); + + var e = assertThrows(IllegalArgumentException.class, () -> ArtifactValidators.requireValidSpecs(config)); + assertTrue(e.getMessage().contains("validators[1]"), e.getMessage()); + } + + // ================================================================= + // write-time: firstRejection + // ================================================================= + + @Test + @DisplayName("content passing the whole chain yields null") + void passingContent_null() { + var validators = List.of( + new ArtifactValidator(ValidatorKind.MAX_LENGTH, "100"), + new ArtifactValidator(ValidatorKind.REGEX, "title"), + new ArtifactValidator(ValidatorKind.JSON_SCHEMA, SCHEMA)); + + assertNull(ArtifactValidators.firstRejection(validators, "{\"title\":\"ok\"}")); + } + + @Test + @DisplayName("MAX_LENGTH rejection names both counts so the model can act") + void maxLength_rejects() { + String rejection = ArtifactValidators.firstRejection( + List.of(new ArtifactValidator(ValidatorKind.MAX_LENGTH, "5")), "123456"); + + assertNotNull(rejection); + assertTrue(rejection.contains("6") && rejection.contains("5"), rejection); + } + + @Test + @DisplayName("REGEX requires a match somewhere in the content") + void regex_rejectsAndPasses() { + var validators = List.of(new ArtifactValidator(ValidatorKind.REGEX, "^# .+")); + + assertNull(ArtifactValidators.firstRejection(validators, "# Heading\nbody")); + String rejection = ArtifactValidators.firstRejection(validators, "no heading here"); + assertNotNull(rejection); + assertTrue(rejection.contains("pattern"), rejection); + } + + @Test + @DisplayName("JSON_SCHEMA distinguishes 'not JSON' from 'JSON that violates the schema'") + void jsonSchema_rejections() { + var validators = List.of(new ArtifactValidator(ValidatorKind.JSON_SCHEMA, SCHEMA)); + + String notJson = ArtifactValidators.firstRejection(validators, "plain prose"); + assertNotNull(notJson); + assertTrue(notJson.contains("valid JSON"), notJson); + + String violates = ArtifactValidators.firstRejection(validators, "{\"other\":1}"); + assertNotNull(violates); + assertTrue(violates.contains("title"), "the violation message must name what is missing: " + violates); + + assertNull(ArtifactValidators.firstRejection(validators, "{\"title\":\"x\"}")); + } + + @Test + @DisplayName("the chain runs in config order — the first failure wins") + void chainOrder_firstFailureWins() { + var validators = List.of( + new ArtifactValidator(ValidatorKind.MAX_LENGTH, "3"), + new ArtifactValidator(ValidatorKind.REGEX, "nope")); + + String rejection = ArtifactValidators.firstRejection(validators, "12345"); + assertNotNull(rejection); + assertTrue(rejection.contains("character"), "the length failure comes first: " + rejection); + } + + @Test + @DisplayName("a catastrophically backtracking pattern aborts on its deadline instead of pinning the turn") + void catastrophicRegex_abortsOnDeadline() { + // (a+)+$ against a long non-matching tail backtracks exponentially — + // unguarded it runs for astronomical time, not the ~500ms budget. + var validators = List.of(new ArtifactValidator(ValidatorKind.REGEX, "(a+)+$")); + String content = "a".repeat(60_000) + "b"; + + long start = System.nanoTime(); + String rejection = ArtifactValidators.firstRejection(validators, content); + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + + assertNotNull(rejection, "a runaway match must refuse the write, not admit it"); + assertTrue(rejection.contains("did not finish in time"), rejection); + assertTrue(elapsedMs < 5_000, "the deadline guard must abort far below the member-turn timeout, took " + elapsedMs + "ms"); + } + + @Test + @DisplayName("a broken spec at write time fails closed — the write is refused, never admitted") + void brokenSpecAtWriteTime_failsClosed() { + assertNotNull(ArtifactValidators.firstRejection( + List.of(new ArtifactValidator(ValidatorKind.REGEX, "[unclosed")), "anything")); + assertNotNull(ArtifactValidators.firstRejection( + List.of(new ArtifactValidator(ValidatorKind.MAX_LENGTH, "many")), "anything")); + assertNotNull(ArtifactValidators.firstRejection( + List.of(new ArtifactValidator(null, null)), "anything")); + } +} diff --git a/src/test/java/ai/labs/eddi/configs/groups/model/AgentGroupConfigurationTest.java b/src/test/java/ai/labs/eddi/configs/groups/model/AgentGroupConfigurationTest.java index 4e047a6a1b..354f0eedc3 100644 --- a/src/test/java/ai/labs/eddi/configs/groups/model/AgentGroupConfigurationTest.java +++ b/src/test/java/ai/labs/eddi/configs/groups/model/AgentGroupConfigurationTest.java @@ -47,13 +47,14 @@ void maxRounds_setter() { @Test void style_allValues() { - assertEquals(7, DiscussionStyle.values().length); + assertEquals(8, DiscussionStyle.values().length); assertNotNull(DiscussionStyle.valueOf("ROUND_TABLE")); assertNotNull(DiscussionStyle.valueOf("PEER_REVIEW")); assertNotNull(DiscussionStyle.valueOf("DEVIL_ADVOCATE")); assertNotNull(DiscussionStyle.valueOf("DELPHI")); assertNotNull(DiscussionStyle.valueOf("DEBATE")); assertNotNull(DiscussionStyle.valueOf("TASK_FORCE")); + assertNotNull(DiscussionStyle.valueOf("NEGOTIATION")); assertNotNull(DiscussionStyle.valueOf("CUSTOM")); } @@ -136,7 +137,10 @@ void discussionPhase_convenienceConstructor_defaults() { @Test void phaseType_allValues() { - assertEquals(11, PhaseType.values().length); + assertEquals(15, PhaseType.values().length); + assertNotNull(PhaseType.valueOf("PROPOSAL")); + assertNotNull(PhaseType.valueOf("BARGAIN")); + assertNotNull(PhaseType.valueOf("RETRO")); assertNotNull(PhaseType.valueOf("OPINION")); assertNotNull(PhaseType.valueOf("CRITIQUE")); assertNotNull(PhaseType.valueOf("REVISION")); @@ -148,6 +152,7 @@ void phaseType_allValues() { assertNotNull(PhaseType.valueOf("PLAN")); assertNotNull(PhaseType.valueOf("EXECUTE")); assertNotNull(PhaseType.valueOf("VERIFY")); + assertNotNull(PhaseType.valueOf("VOTE")); } @Test @@ -236,9 +241,54 @@ void protocol_setAndGet() { @Test void memberType_allValues() { - assertEquals(2, MemberType.values().length); + assertEquals(3, MemberType.values().length); assertNotNull(MemberType.valueOf("AGENT")); assertNotNull(MemberType.valueOf("GROUP")); + assertNotNull(MemberType.valueOf("HUMAN")); + } + + // ==================== Facilitator (I12) ==================== + + @Test + void facilitatorMove_allValues() { + assertEquals(6, FacilitatorMove.values().length); + assertNotNull(FacilitatorMove.valueOf("CONTINUE")); + assertNotNull(FacilitatorMove.valueOf("END_PHASE")); + assertNotNull(FacilitatorMove.valueOf("EXTEND_PHASE")); + assertNotNull(FacilitatorMove.valueOf("CALL_VOTE")); + assertNotNull(FacilitatorMove.valueOf("RECRUIT")); + assertNotNull(FacilitatorMove.valueOf("ESCALATE_HUMAN")); + } + + @Test + void facilitatorCheckpoint_allValues() { + assertEquals(2, FacilitatorCheckpoint.values().length); + assertNotNull(FacilitatorCheckpoint.valueOf("EACH_PHASE")); + assertNotNull(FacilitatorCheckpoint.valueOf("EACH_REPEAT")); + } + + @Test + void facilitatorConfig_compactConstructor_normalizesDefaults() { + var sparse = new FacilitatorConfig(true, "fac", null, null, 0, null); + + assertEquals(List.of(FacilitatorMove.CONTINUE), sparse.allowedMoves(), + "an enabled-but-unconfigured facilitator is a pure observer"); + assertEquals(FacilitatorCheckpoint.EACH_PHASE, sparse.checkAfter()); + assertEquals(FacilitatorConfig.DEFAULT_MAX_MOVES, sparse.maxMovesPerDiscussion()); + + var emptyMoves = new FacilitatorConfig(true, "fac", List.of(), FacilitatorCheckpoint.EACH_REPEAT, -5, "boss"); + assertEquals(List.of(FacilitatorMove.CONTINUE), emptyMoves.allowedMoves()); + assertEquals(FacilitatorConfig.DEFAULT_MAX_MOVES, emptyMoves.maxMovesPerDiscussion(), + "non-positive caps fall back to the default, never to unlimited"); + } + + @Test + void facilitatorConfig_noArgConstructor_isDisabledObserver() { + var config = new FacilitatorConfig(); + + assertFalse(config.enabled()); + assertEquals(List.of(FacilitatorMove.CONTINUE), config.allowedMoves()); + assertEquals(FacilitatorCheckpoint.EACH_PHASE, config.checkAfter()); } // ==================== LifecyclePolicy ==================== diff --git a/src/test/java/ai/labs/eddi/configs/groups/model/DiscussionStylePresetsTest.java b/src/test/java/ai/labs/eddi/configs/groups/model/DiscussionStylePresetsTest.java index 541602a54c..2c93d11f11 100644 --- a/src/test/java/ai/labs/eddi/configs/groups/model/DiscussionStylePresetsTest.java +++ b/src/test/java/ai/labs/eddi/configs/groups/model/DiscussionStylePresetsTest.java @@ -267,6 +267,48 @@ void taskForce_phaseTypes() { assertEquals(PhaseType.SYNTHESIS, phases.get(3).type()); } + // --- NEGOTIATION (I11) --- + + @Test + void negotiation_produces5Phases_withTheProtocolShape() { + List phases = DiscussionStylePresets.expand(DiscussionStyle.NEGOTIATION, 3); + + assertEquals(5, phases.size()); + + // ① Positions & Interests — PARALLEL and context-free: parties state + // genuine interests before anchoring on each other. + var positions = phases.get(0); + assertEquals(PhaseType.OPINION, positions.type()); + assertEquals(TurnOrder.PARALLEL, positions.turnOrder()); + assertEquals(ContextScope.NONE, positions.contextScope()); + + // ② Opening Proposals + var proposals = phases.get(1); + assertEquals(PhaseType.PROPOSAL, proposals.type()); + assertEquals("ALL", proposals.participants()); + + // ③ Bargaining — repeats = maxRounds; the loop exits early on agreement. + var bargaining = phases.get(2); + assertEquals(PhaseType.BARGAIN, bargaining.type()); + assertEquals(3, bargaining.repeats()); + assertEquals(TurnOrder.SEQUENTIAL, bargaining.turnOrder()); + + // ④ Arbitration — MODERATOR, skipped entirely when agreement was reached, + // with its own template (the default SYNTHESIS asks for a balanced + // summary; an arbitrator DECIDES). + var arbitration = phases.get(3); + assertEquals(PhaseType.SYNTHESIS, arbitration.type()); + assertEquals("MODERATOR", arbitration.participants()); + assertEquals(AgentGroupConfiguration.PhaseSkipCondition.AGREEMENT_REACHED, arbitration.skipIf()); + assertEquals(DiscussionStylePresets.TEMPLATE_ARBITRATION, arbitration.inputTemplate()); + + // ⑤ Synthesis + var synthesis = phases.get(4); + assertEquals(PhaseType.SYNTHESIS, synthesis.type()); + assertEquals("MODERATOR", synthesis.participants()); + assertNull(synthesis.skipIf(), "only the arbitration is conditional"); + } + @Test void taskForce_turnOrders() { List phases = DiscussionStylePresets.expand(DiscussionStyle.TASK_FORCE, 1); diff --git a/src/test/java/ai/labs/eddi/configs/groups/model/GroupConversationHitlTest.java b/src/test/java/ai/labs/eddi/configs/groups/model/GroupConversationHitlTest.java index 0cdd4bc001..9f579c37e9 100644 --- a/src/test/java/ai/labs/eddi/configs/groups/model/GroupConversationHitlTest.java +++ b/src/test/java/ai/labs/eddi/configs/groups/model/GroupConversationHitlTest.java @@ -49,9 +49,10 @@ void awaitingApprovalExists() { void valuesContainAllExpected() { var values = GroupConversationState.values(); // CREATED, IN_PROGRESS, SYNTHESIZING, COMPLETED, FAILED, CANCELLED, - // AWAITING_APPROVAL, CLOSED (CLOSED added by the group follow-up feature) - assertEquals(8, values.length); + // AWAITING_APPROVAL, AWAITING_HUMAN_INPUT (I6), CLOSED + assertEquals(9, values.length); assertEquals(GroupConversationState.CLOSED, GroupConversationState.valueOf("CLOSED")); + assertEquals(GroupConversationState.AWAITING_HUMAN_INPUT, GroupConversationState.valueOf("AWAITING_HUMAN_INPUT")); } } @@ -74,9 +75,10 @@ void taskExists() { } @Test - @DisplayName("values() has exactly 2 members") + @DisplayName("values() has exactly 3 members") void valuesCount() { - assertEquals(2, HitlPauseType.values().length); + assertEquals(3, HitlPauseType.values().length); + assertNotNull(HitlPauseType.valueOf("HUMAN_TURN")); } } diff --git a/src/test/java/ai/labs/eddi/configs/groups/model/GroupConversationTest.java b/src/test/java/ai/labs/eddi/configs/groups/model/GroupConversationTest.java index 94891f118e..29e89fdf85 100644 --- a/src/test/java/ai/labs/eddi/configs/groups/model/GroupConversationTest.java +++ b/src/test/java/ai/labs/eddi/configs/groups/model/GroupConversationTest.java @@ -294,11 +294,12 @@ void transcriptEntryTypes() { @DisplayName("GroupConversationState — all values") void groupConversationStates() { var values = GroupConversationState.values(); - assertEquals(8, values.length); + assertEquals(9, values.length); assertNotNull(GroupConversationState.valueOf("CREATED")); assertNotNull(GroupConversationState.valueOf("COMPLETED")); assertNotNull(GroupConversationState.valueOf("FAILED")); assertNotNull(GroupConversationState.valueOf("AWAITING_APPROVAL")); + assertNotNull(GroupConversationState.valueOf("AWAITING_HUMAN_INPUT")); assertNotNull(GroupConversationState.valueOf("CLOSED")); assertNotNull(GroupConversationState.valueOf("CANCELLED")); } diff --git a/src/test/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStoreTest.java b/src/test/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStoreTest.java index d9c1c087b0..e11d464869 100644 --- a/src/test/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStoreTest.java +++ b/src/test/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStoreTest.java @@ -8,11 +8,20 @@ import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.ContextScope; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.DiscussionPhase; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.DiscussionStyle; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.GroupMember; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.HumanMemberConfig; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.MemberType; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.OnHumanTimeout; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.OptionsSource; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.PhaseType; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.TiePolicy; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.TurnOrder; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.VoteConfig; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.VoteMethod; import org.junit.jupiter.api.Test; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -87,4 +96,252 @@ void noModeratorPhasesAtAll_isSilent() { assertTrue(AgentGroupStore.moderatorlessPhaseNames(config(DiscussionStyle.CUSTOM, phases, null)).isEmpty()); } + + // ================================================================= + // I14 — VOTE phase validation: independence enforced, not advised + // ================================================================= + + private DiscussionPhase votePhase(TurnOrder turnOrder, ContextScope scope, VoteConfig voteConfig) { + return new DiscussionPhase("Ballot", PhaseType.VOTE, "ALL", turnOrder, scope, false, null, 1, false, null, false, voteConfig); + } + + private AgentGroupConfiguration voteGroup(DiscussionPhase phase) { + return config(DiscussionStyle.CUSTOM, List.of(phase), "mod"); + } + + @Test + void votePhase_parallelAndNone_isAccepted() { + assertDoesNotThrow(() -> AgentGroupStore.validateVotePhases(voteGroup( + votePhase(TurnOrder.PARALLEL, ContextScope.NONE, + new VoteConfig(VoteMethod.MAJORITY, OptionsSource.EXPLICIT, List.of("A", "B"), 0.5, Map.of(), false, + TiePolicy.MODERATOR_DECIDES))))); + // A null scope behaves as NONE throughout the scope filter, so it is accepted. + assertDoesNotThrow(() -> AgentGroupStore.validateVotePhases(voteGroup(votePhase(TurnOrder.PARALLEL, null, null)))); + } + + @Test + void votePhase_sequential_isRejected() { + var ex = assertThrows(IllegalArgumentException.class, () -> AgentGroupStore.validateVotePhases(voteGroup( + votePhase(TurnOrder.SEQUENTIAL, ContextScope.NONE, null)))); + assertTrue(ex.getMessage().contains("PARALLEL"), ex.getMessage()); + } + + @Test + void votePhase_contextfulScope_isRejected() { + var ex = assertThrows(IllegalArgumentException.class, () -> AgentGroupStore.validateVotePhases(voteGroup( + votePhase(TurnOrder.PARALLEL, ContextScope.FULL, null)))); + assertTrue(ex.getMessage().contains("NONE"), ex.getMessage()); + } + + @Test + void votePhase_explicitWithTooFewOptions_isRejected() { + var ex = assertThrows(IllegalArgumentException.class, () -> AgentGroupStore.validateVotePhases(voteGroup( + votePhase(TurnOrder.PARALLEL, ContextScope.NONE, + new VoteConfig(VoteMethod.MAJORITY, OptionsSource.EXPLICIT, List.of("only one"), 0.5, Map.of(), false, + TiePolicy.NO_DECISION))))); + assertTrue(ex.getMessage().contains("2 options"), ex.getMessage()); + } + + @Test + void votePhase_humanDecides_isRejectedUntilI6() { + var ex = assertThrows(IllegalArgumentException.class, () -> AgentGroupStore.validateVotePhases(voteGroup( + votePhase(TurnOrder.PARALLEL, ContextScope.NONE, + new VoteConfig(VoteMethod.MAJORITY, OptionsSource.EXPLICIT, List.of("A", "B"), 0.5, Map.of(), false, + TiePolicy.HUMAN_DECIDES))))); + assertTrue(ex.getMessage().contains("I6"), ex.getMessage()); + } + + @Test + void votePhase_negativeWeight_isRejected() { + var ex = assertThrows(IllegalArgumentException.class, () -> AgentGroupStore.validateVotePhases(voteGroup( + votePhase(TurnOrder.PARALLEL, ContextScope.NONE, + new VoteConfig(VoteMethod.MAJORITY, OptionsSource.EXPLICIT, List.of("A", "B"), 0.5, + Map.of("a1", -1.0), false, TiePolicy.NO_DECISION))))); + assertTrue(ex.getMessage().contains("weights"), ex.getMessage()); + } + + @Test + void votePhase_nonFiniteWeight_isRejected() { + // NaN passes every < comparison; infinity would decide every vote alone. + for (double bad : new double[]{Double.NaN, Double.POSITIVE_INFINITY}) { + var ex = assertThrows(IllegalArgumentException.class, () -> AgentGroupStore.validateVotePhases(voteGroup( + votePhase(TurnOrder.PARALLEL, ContextScope.NONE, + new VoteConfig(VoteMethod.MAJORITY, OptionsSource.EXPLICIT, List.of("A", "B"), 0.5, + Map.of("a1", bad), false, TiePolicy.NO_DECISION)))), + "weight " + bad + " must be rejected"); + assertTrue(ex.getMessage().contains("finite"), ex.getMessage()); + } + } + + // ================================================================= + // I6 — HUMAN member save-time matrix + // ================================================================= + + private GroupMember human(String id, String name) { + return new GroupMember(id, name, 1, null, MemberType.HUMAN); + } + + private AgentGroupConfiguration humanConfig(DiscussionStyle style, List phases, GroupMember... members) { + var c = config(style, phases, null); + c.setMembers(List.of(members)); + return c; + } + + @Test + void humanMember_withoutDisplayName_isRejected() { + var problems = AgentGroupStore.humanMemberProblems(humanConfig(DiscussionStyle.CUSTOM, + List.of(phase("Open", "ALL")), human("h-1", " "))); + + assertEquals(1, problems.size()); + assertTrue(problems.get(0).contains("displayName"), problems.toString()); + } + + @Test + void humanMember_inTaskForceGroup_isRejected_presetExpanded() { + // TASK_FORCE stores NO phases — the preset expansion is what makes this + // check reach PLAN/EXECUTE/VERIFY at all. + var problems = AgentGroupStore.humanMemberProblems(humanConfig(DiscussionStyle.TASK_FORCE, + null, human("h-1", "Hannah"))); + + assertFalse(problems.isEmpty()); + assertTrue(problems.stream().anyMatch(p -> p.contains("task-force")), problems.toString()); + } + + @Test + void humanMember_inTargetEachPeerPhase_isRejected() { + var peerPhase = new DiscussionPhase("Critique", PhaseType.CRITIQUE, "ALL", TurnOrder.SEQUENTIAL, + ContextScope.FULL, true, null, 1, false); + var problems = AgentGroupStore.humanMemberProblems(humanConfig(DiscussionStyle.CUSTOM, + List.of(peerPhase), human("h-1", "Hannah"))); + + assertFalse(problems.isEmpty()); + assertTrue(problems.stream().anyMatch(p -> p.contains("targetEachPeer")), problems.toString()); + } + + @Test + void humanMember_inPlainSequentialGroup_isAccepted() { + var config = humanConfig(DiscussionStyle.CUSTOM, List.of(phase("Open", "ALL")), + human("h-1", "Hannah"), new GroupMember("a-1", "Agent", 2, null)); + config.setHumanMemberConfig(new HumanMemberConfig("PT4H", OnHumanTimeout.SKIP_TURN)); + + assertTrue(AgentGroupStore.humanMemberProblems(config).isEmpty()); + } + + @Test + void humanTimeout_notIso8601_isRejected() { + var config = humanConfig(DiscussionStyle.CUSTOM, List.of(phase("Open", "ALL")), human("h-1", "Hannah")); + config.setHumanMemberConfig(new HumanMemberConfig("4 hours", null)); + + var problems = AgentGroupStore.humanMemberProblems(config); + + assertEquals(1, problems.size()); + assertTrue(problems.get(0).contains("ISO-8601"), problems.toString()); + } + + @Test + void humanTimeout_zeroOrNegative_isRejected() { + // Duration.parse accepts both; armed, they would fire effectively + // immediately and silently skip every human turn. + for (String bad : new String[]{"PT0S", "PT-4H"}) { + var config = humanConfig(DiscussionStyle.CUSTOM, List.of(phase("Open", "ALL")), human("h-1", "Hannah")); + config.setHumanMemberConfig(new HumanMemberConfig(bad, null)); + + var problems = AgentGroupStore.humanMemberProblems(config); + + assertEquals(1, problems.size(), bad); + assertTrue(problems.get(0).contains("positive"), problems.toString()); + } + } + + @Test + void nonVotePhases_areNeverTouchedByVoteValidation() { + assertDoesNotThrow(() -> AgentGroupStore.validateVotePhases(config(DiscussionStyle.CUSTOM, + List.of(phase("Open", "ALL")), null))); + assertDoesNotThrow(() -> AgentGroupStore.validateVotePhases(config(DiscussionStyle.DEBATE, null, null))); + } + + @Test + void humanValidation_nullMembersList_neverNPEs() { + var config = config(DiscussionStyle.CUSTOM, List.of(phase("Open", "ALL")), "mod"); + config.setMembers(null); + + assertTrue(AgentGroupStore.humanMemberProblems(config).isEmpty()); + assertFalse(AgentGroupStore.hasHumanMembers(config)); + } + + @Test + void agentOnlyGroups_produceNoHumanProblems() { + assertTrue(AgentGroupStore.humanMemberProblems(config(DiscussionStyle.TASK_FORCE, null, null)).isEmpty(), + "the whole matrix only applies when a HUMAN member exists"); + assertFalse(AgentGroupStore.hasHumanMembers(config(DiscussionStyle.CUSTOM, null, null))); + assertTrue(AgentGroupStore.hasHumanMembers(humanConfig(DiscussionStyle.CUSTOM, null, human("h", "H")))); + } + + // ================================================================= + // I12 — facilitator save-time matrix + // ================================================================= + + private AgentGroupConfiguration facilitatorConfig(AgentGroupConfiguration.FacilitatorConfig facilitator) { + var c = config(DiscussionStyle.CUSTOM, List.of(phase("Open", "ALL")), null); + c.setFacilitator(facilitator); + return c; + } + + @Test + void facilitator_enabledWithoutAgentId_isRejected() { + var ex = assertThrows(IllegalArgumentException.class, () -> AgentGroupStore.validateFacilitator( + facilitatorConfig(new AgentGroupConfiguration.FacilitatorConfig(true, " ", null, null, 0, null)))); + assertTrue(ex.getMessage().contains("agentId"), ex.getMessage()); + } + + @Test + void facilitator_midPhaseMovesWithPhaseBoundaryCadence_isRejected() { + // END_PHASE/EXTEND_PHASE act on remaining repeats; an EACH_PHASE checkpoint + // has none — the config could only ever produce rejected attempts. + for (var move : new AgentGroupConfiguration.FacilitatorMove[]{ + AgentGroupConfiguration.FacilitatorMove.END_PHASE, + AgentGroupConfiguration.FacilitatorMove.EXTEND_PHASE}) { + var ex = assertThrows(IllegalArgumentException.class, () -> AgentGroupStore.validateFacilitator( + facilitatorConfig(new AgentGroupConfiguration.FacilitatorConfig(true, "fac", List.of(move), + AgentGroupConfiguration.FacilitatorCheckpoint.EACH_PHASE, 10, null))), + move.name()); + assertTrue(ex.getMessage().contains("EACH_REPEAT"), ex.getMessage()); + } + assertDoesNotThrow(() -> AgentGroupStore.validateFacilitator( + facilitatorConfig(new AgentGroupConfiguration.FacilitatorConfig(true, "fac", + List.of(AgentGroupConfiguration.FacilitatorMove.END_PHASE), + AgentGroupConfiguration.FacilitatorCheckpoint.EACH_REPEAT, 10, null)))); + } + + @Test + void facilitator_escalateWithoutPrincipal_isRejected() { + var ex = assertThrows(IllegalArgumentException.class, () -> AgentGroupStore.validateFacilitator( + facilitatorConfig(new AgentGroupConfiguration.FacilitatorConfig(true, "fac", + List.of(AgentGroupConfiguration.FacilitatorMove.ESCALATE_HUMAN), + AgentGroupConfiguration.FacilitatorCheckpoint.EACH_PHASE, 10, " ")))); + assertTrue(ex.getMessage().contains("escalateTo"), ex.getMessage()); + + assertDoesNotThrow(() -> AgentGroupStore.validateFacilitator( + facilitatorConfig(new AgentGroupConfiguration.FacilitatorConfig(true, "fac", + List.of(AgentGroupConfiguration.FacilitatorMove.ESCALATE_HUMAN), + AgentGroupConfiguration.FacilitatorCheckpoint.EACH_PHASE, 10, "boss@example.com")))); + } + + @Test + void facilitator_excessiveMoveBudget_isRejected() { + var ex = assertThrows(IllegalArgumentException.class, () -> AgentGroupStore.validateFacilitator( + facilitatorConfig(new AgentGroupConfiguration.FacilitatorConfig(true, "fac", null, null, 101, null)))); + assertTrue(ex.getMessage().contains("100"), ex.getMessage()); + } + + @Test + void facilitator_disabledOrAbsent_isNeverValidated() { + assertDoesNotThrow(() -> AgentGroupStore.validateFacilitator(facilitatorConfig(null))); + // Disabled: even a shape that would be rejected when enabled passes — + // an operator may stage a config before switching it on. + assertDoesNotThrow(() -> AgentGroupStore.validateFacilitator( + facilitatorConfig(new AgentGroupConfiguration.FacilitatorConfig(false, null, + List.of(AgentGroupConfiguration.FacilitatorMove.ESCALATE_HUMAN), + AgentGroupConfiguration.FacilitatorCheckpoint.EACH_PHASE, 10, null)))); + } } diff --git a/src/test/java/ai/labs/eddi/configs/groups/mongo/GroupWorkspaceStoreTest.java b/src/test/java/ai/labs/eddi/configs/groups/mongo/GroupWorkspaceStoreTest.java new file mode 100644 index 0000000000..6bc01adc7e --- /dev/null +++ b/src/test/java/ai/labs/eddi/configs/groups/mongo/GroupWorkspaceStoreTest.java @@ -0,0 +1,183 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.configs.groups.mongo; + +import ai.labs.eddi.configs.groups.model.GroupWorkspace; +import ai.labs.eddi.datastore.IResourceStorage; +import ai.labs.eddi.datastore.IResourceStorageFactory; +import ai.labs.eddi.datastore.IResourceStore; +import ai.labs.eddi.datastore.serialization.IDocumentBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * I13 review round 2 — {@link GroupWorkspaceStore}: the revision-checked write + * and the duplicate-insert convergence guard (no unique constraint exists in + * the storage abstraction, so the store must converge racers itself). + */ +class GroupWorkspaceStoreTest { + + private static final String GROUP_ID = "group-1"; + + private IResourceStorage storage; + private GroupWorkspaceStore store; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() { + storage = mock(IResourceStorage.class); + var factory = mock(IResourceStorageFactory.class); + when(factory.create(anyString(), any(), eq(GroupWorkspace.class), any())).thenReturn(storage); + store = new GroupWorkspaceStore(factory, mock(IDocumentBuilder.class)); + } + + private IResourceStore.IResourceId resourceId(String id) { + var resourceId = mock(IResourceStore.IResourceId.class); + lenient().when(resourceId.getId()).thenReturn(id); + lenient().when(resourceId.getVersion()).thenReturn(1); + return resourceId; + } + + @SuppressWarnings("unchecked") + private IResourceStorage.IResource resource(GroupWorkspace workspace, String id) throws Exception { + IResourceStorage.IResource resource = mock(IResourceStorage.IResource.class); + lenient().when(resource.getData()).thenReturn(workspace); + lenient().when(resource.getId()).thenReturn(id); + return resource; + } + + // ================================================================= + // casRevision + // ================================================================= + + @Test + @DisplayName("a matching revision writes conditionally and bumps the stamp") + void casRevision_match_bumpsAndStoresConditionally() throws Exception { + var workspace = new GroupWorkspace(); + workspace.setId("ws-1"); + workspace.setGroupId(GROUP_ID); + workspace.setRevision("4"); + var res = resource(workspace, "ws-1"); + when(storage.newResource(eq("ws-1"), anyInt(), eq(workspace))).thenReturn(res); + + assertTrue(store.casRevision(workspace)); + + assertEquals("5", workspace.getRevision()); + verify(storage).storeIfFieldEquals(res, "revision", "4"); + verify(storage, never()).store(any(IResourceStorage.IResource.class)); + } + + @Test + @DisplayName("a lost revision race returns false and restores the caller's stamp for the re-read") + void casRevision_mismatch_returnsFalseAndRestores() throws Exception { + var workspace = new GroupWorkspace(); + workspace.setId("ws-1"); + workspace.setGroupId(GROUP_ID); + workspace.setRevision("4"); + var res = resource(workspace, "ws-1"); + when(storage.newResource(eq("ws-1"), anyInt(), eq(workspace))).thenReturn(res); + doThrow(new IResourceStore.ResourceModifiedException("raced")) + .when(storage).storeIfFieldEquals(any(), eq("revision"), eq("4")); + + assertFalse(store.casRevision(workspace)); + + assertEquals("4", workspace.getRevision(), "a failed CAS must not leave a phantom bump on the instance"); + } + + @Test + @DisplayName("a pre-revision document is stamped with one plain write, then CAS'd forever after") + void casRevision_legacyNullRevision_stampsWithPlainWrite() throws Exception { + var workspace = new GroupWorkspace(); + workspace.setId("ws-1"); + workspace.setGroupId(GROUP_ID); + workspace.setRevision(null); + var res = resource(workspace, "ws-1"); + when(storage.newResource(eq("ws-1"), anyInt(), eq(workspace))).thenReturn(res); + + assertTrue(store.casRevision(workspace)); + + assertEquals("1", workspace.getRevision()); + verify(storage).store(any(IResourceStorage.IResource.class)); + verify(storage, never()).storeIfFieldEquals(any(), anyString(), anyString()); + } + + @Test + @DisplayName("a corrupt (non-numeric) revision surfaces as the declared store exception, not a raw NumberFormatException") + void casRevision_corruptRevision_throwsDeclaredException() { + var workspace = new GroupWorkspace(); + workspace.setId("ws-1"); + workspace.setGroupId(GROUP_ID); + workspace.setRevision("not-a-number"); + + var thrown = org.junit.jupiter.api.Assertions.assertThrows( + IResourceStore.ResourceStoreException.class, () -> store.casRevision(workspace)); + + assertTrue(thrown.getMessage().contains("not-a-number"), thrown.getMessage()); + assertEquals("not-a-number", workspace.getRevision(), "the corrupt value is left for diagnosis, never bumped"); + } + + // ================================================================= + // readOrCreate duplicate convergence + // ================================================================= + + @Test + @DisplayName("a raced readOrCreate deletes its own duplicate and adopts the deterministic survivor") + void readOrCreate_race_convergesOnSurvivor() throws Exception { + // First find(): nothing yet (both racers miss). After OUR insert (id + // "bbb"), the re-query sees TWO documents; the survivor is "aaa". + var idBbb = resourceId("bbb"); + var idAaa = resourceId("aaa"); + var inserted = resource(new GroupWorkspace(), "bbb"); + var survivor = new GroupWorkspace(); + survivor.setGroupId(GROUP_ID); + var survivorResource = resource(survivor, "aaa"); + when(storage.findResources(any(), eq("lastModified"), eq(0), eq(50))) + .thenReturn(List.of()) + .thenReturn(List.of(idBbb, idAaa)) + .thenReturn(List.of(idAaa)); + when(storage.newResource(any(GroupWorkspace.class))).thenReturn(inserted); + when(storage.read("aaa", 1)).thenReturn(survivorResource); + + GroupWorkspace result = store.readOrCreate(GROUP_ID); + + verify(storage).removeAllPermanently("bbb"); + assertEquals("aaa", result.getId(), "the loser adopts the earlier insert"); + } + + @Test + @DisplayName("find() with lingering duplicates always reads the same survivor") + void find_duplicates_readsDeterministicSurvivor() throws Exception { + var idBbb = resourceId("bbb"); + var idAaa = resourceId("aaa"); + var survivor = new GroupWorkspace(); + survivor.setGroupId(GROUP_ID); + var survivorResource = resource(survivor, "aaa"); + when(storage.findResources(any(), eq("lastModified"), eq(0), eq(50))) + .thenReturn(List.of(idBbb, idAaa)); + when(storage.read("aaa", 1)).thenReturn(survivorResource); + + GroupWorkspace result = store.find(GROUP_ID); + + assertEquals("aaa", result.getId(), + "sorting by id, not lastModified — every reader and both racers must agree"); + } +} diff --git a/src/test/java/ai/labs/eddi/configs/groups/mongo/SharedArtifactStoreTest.java b/src/test/java/ai/labs/eddi/configs/groups/mongo/SharedArtifactStoreTest.java new file mode 100644 index 0000000000..7be2198ecc --- /dev/null +++ b/src/test/java/ai/labs/eddi/configs/groups/mongo/SharedArtifactStoreTest.java @@ -0,0 +1,292 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.configs.groups.mongo; + +import ai.labs.eddi.configs.groups.ISharedArtifactStore.ArtifactGoneException; +import ai.labs.eddi.configs.groups.model.SharedArtifact; +import ai.labs.eddi.datastore.IResourceFilter; +import ai.labs.eddi.datastore.IResourceStorage; +import ai.labs.eddi.datastore.IResourceStorageFactory; +import ai.labs.eddi.datastore.IResourceStore; +import ai.labs.eddi.datastore.serialization.IDocumentBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * I17 — {@link SharedArtifactStore}: the version CAS goes through the numeric + * {@code storeIfFieldEquals} overload (never an unconditional store), filters + * are anchored and re-checked in Java, and the GDPR erasure sweep follows the + * group-conversation store's page/re-check/fail-loud contract. + * + * @author tests + */ +@SuppressWarnings("unchecked") +class SharedArtifactStoreTest { + + private IResourceStorage storage; + private SharedArtifactStore store; + + @BeforeEach + void setUp() { + IResourceStorageFactory storageFactory = mock(IResourceStorageFactory.class); + IDocumentBuilder documentBuilder = mock(IDocumentBuilder.class); + storage = mock(IResourceStorage.class); + // Must mirror the production create() call EXACTLY, index-hint varargs + // included — a mismatch silently leaves the internal storage null. + when(storageFactory.create(eq("sharedartifacts"), eq(documentBuilder), eq(SharedArtifact.class), + eq("groupConversationId"), eq("ownerUserId"))).thenReturn(storage); + store = new SharedArtifactStore(storageFactory, documentBuilder); + } + + private static SharedArtifact artifact(String id, String gcId, String owner, long version) { + var a = new SharedArtifact(); + a.setId(id); + a.setGroupConversationId(gcId); + a.setOwnerUserId(owner); + a.setName("draft"); + a.setVersion(version); + return a; + } + + private IResourceStorage.IResource resource(String id, SharedArtifact data) throws IOException { + IResourceStorage.IResource resource = mock(IResourceStorage.IResource.class); + when(resource.getId()).thenReturn(id); + when(resource.getData()).thenReturn(data); + return resource; + } + + private IResourceStore.IResourceId resourceId(String id) { + return new IResourceStore.IResourceId() { + @Override + public String getId() { + return id; + } + + @Override + public Integer getVersion() { + return 1; + } + }; + } + + // ================================================================= + // CAS + // ================================================================= + + @Test + @DisplayName("updateIfVersion goes through the NUMERIC CAS overload, never an unconditional store") + void updateIfVersion_usesNumericCas() throws Exception { + var a = artifact("a-1", "gc-1", "user-1", 3); + IResourceStorage.IResource writeResource = resource("a-1", a); + when(storage.newResource("a-1", 1, a)).thenReturn(writeResource); + + store.updateIfVersion(a, 2); + + verify(storage).storeIfFieldEquals(writeResource, "version", 2L); + verify(storage, never()).store(any(IResourceStorage.IResource.class)); + verify(storage, never()).storeIfFieldEquals(any(), anyString(), anyString()); + } + + @Test + @DisplayName("a CAS conflict propagates as ResourceModifiedException (retry), a gone document as ArtifactGoneException") + void updateIfVersion_distinguishesConflictFromGone() throws Exception { + var a = artifact("a-1", "gc-1", "user-1", 3); + IResourceStorage.IResource writeResource = resource("a-1", a); + when(storage.newResource("a-1", 1, a)).thenReturn(writeResource); + + doThrow(new IResourceStore.ResourceModifiedException("conflict")) + .when(storage).storeIfFieldEquals(writeResource, "version", 2L); + assertThrows(IResourceStore.ResourceModifiedException.class, () -> store.updateIfVersion(a, 2)); + + doThrow(new IResourceStore.ResourceNotFoundException("gone")) + .when(storage).storeIfFieldEquals(writeResource, "version", 2L); + assertThrows(ArtifactGoneException.class, () -> store.updateIfVersion(a, 2)); + } + + // ================================================================= + // read / create + // ================================================================= + + @Test + @DisplayName("read maps a missing document to a not-found whose message does NOT embed the caller-supplied id") + void readNotFound_messageDoesNotEmbedTheId() { + when(storage.read("attacker