Skip to content

fix(agents): dynamic-agent guardrails — permissive fallback on resume, per-member caps, duplicate recruits, V7 - #649

Merged
ginccc merged 5 commits into
mainfrom
fix/dynamic-agent-guardrails
Aug 10, 2026
Merged

fix(agents): dynamic-agent guardrails — permissive fallback on resume, per-member caps, duplicate recruits, V7#649
ginccc merged 5 commits into
mainfrom
fix/dynamic-agent-guardrails

Conversation

@ginccc

@ginccc ginccc commented Aug 9, 2026

Copy link
Copy Markdown
Member

Wave B of the Agent / Group Agent review (Wave A is #648). These guard the highest-blast-radius capability in the product — an LLM deploying agents to production — and the review found them to be the weakest-enforced things in the system.

1. 🔴 A group's dynamicAgents policy silently reverted to fully permissive on a resumed member turn

resolveDynamicAgentConfig accepted only a typed DynamicAgentConfig out of the context value. It is the only place in the codebase that does a typed cast on a Context value — every other consumer (GroupCostLedger, GroupLifecycleOps, AttachmentContextExtractor, OpenAiConversationBridge) handles the deserialized shape.

A Context whose value round-trips through the conversation store comes back as a raw LinkedHashMapConversationMemoryStore rebuilds it as new Context(type, map.get("value")). So any turn running against a reloaded step missed the instanceof and fell through to createDefaultDynamicConfig(): creation, recruitment and delegation all on, for a group that may have disabled every one of them.

The trigger is an ordinary group path, not an exotic one:

  1. A member agent's gated tool call fires inside a group.
  2. MemberTurnExecutor#tryResolveMemberToolPause auto-rejects it (system:group) and resumes the member conversation.
  3. ConversationHitlService loads the snapshot from the store; Conversation#resume re-enters the same LlmTask at the same index (executeLifecycleFromIndex).
  4. buildToolList runs again against the reloaded step → permissive default.

The orchestrator is still blocked inside that call, so the discussion is also still live in LiveDiscussionRegistry and the group-gated tools are available on the same leg.

Resolution is now three-state and fails closed:

Context key Meaning Result
absent standalone agent permissive default (operator opted in via the whitelist)
present, readable (typed or map) the group's policy that policy
present, unreadable a group is in charge, we cannot parse what it said every capability off

"The operator said something we cannot parse" must never resolve to "the operator said yes to everything".

2. maxCreatedAgentsPerDiscussion was enforced per member, not per discussion

seedCreatedAgentIds has always read a dynamicCreatedAgentIds context variable for the discussion-wide total — its own Javadoc said "Nothing in src/main writes it today". So the cap bounded each member conversation independently: a 5-member group with the default cap of 5 could deploy 25 agents to production, while both the field name and docs/group-conversations.md promise 5.

MemberTurnExecutor now injects gc.getCreatedAgentIds() alongside the policy it already injects per turn. The read side was already written and waiting.

3. RecruitAgentTool could re-recruit a configured member

The Javadoc claimed "Configured roster and prior recruits both count", but isAlreadyMember checked only recruitedAgentIds, dynamicMembers and memberConversationIds — and the last holds an agent only once it has spoken. A member whose first turn had not come up yet (any member, during the opening phase) could be "recruited" as a duplicate.

rosterWithRecruits de-duplicates so nobody spoke twice, but: the recruitment cap was consumed, a misleading FACILITATION entry was written, and addMemberDisplayName overwrote the operator-chosen display name with the raw agent id — visible in the UI and in followUpWithMember's name resolution.

The tool now receives the configured roster (resolved the same way ArtifactToolsProvider resolves its artifact policy), and display-name recording became putIfAbsent.

4. Teardown never freed a creation slot, and a failed delete orphaned the agent

Two problems in TeardownAgentTool:

  • createdAgentIds.remove ran before the delete, so a failed delete left the agent untracked and cleanupEphemeralAgents never retried it — config and deployment record orphaned.
  • The removal was from a per-turn list that seedCreatedAgentIds rebuilds by unioning every earlier step, so the id came straight back and the cap counted a non-existent agent forever — defeating the tool's entire purpose.

Teardown now records into dynamic:torn_down_agent_ids, which the seed subtracts and propagateDynamicAgentTracking applies to the group's own tracking. The tracking removal moved after the successful delete.

5. V7 resolved — an omitted whitelist no longer skips the dynamic tools

docs/langchain.md states twice that omitting builtInToolsWhitelist enables all built-in tools ("(all if not specified)", "Omitting builtInToolsWhitelist enables all available built-in tools"), and BuiltinToolsProvider implements exactly that for the nine plain beans. This provider alone returned early, so an agent with enableBuiltInTools=true, no whitelist and dynamicAgents.enabled=true got none of them. Tracked as verify-task V7 in planning/group-collaboration-improvements-plan.md §2, which sanctions fixing it as a separate labeled behaviour-change commit.

Deliberately narrower than "all" in one respect: the omitted case is honoured only when a group policy governs the turn. dynamicAgents is a field on AgentGroupConfiguration, so a standalone conversation has no configuration surface on which an operator could have declined — handing it unconfigurable, production-deploying capabilities because it omitted a list would be a worse defect than the asymmetry being fixed. Under a group policy the operator has that surface (enabled / allowCreation / allowRecruitment / allowDelegation), which is what makes "all" safe to mean all.

The existing AgentOrchestratorBuiltInToolWiringTest turned out not to pin the no-whitelist case at all, so nothing had to be un-pinned; new coverage pins it in both directions.

Also

The three dynamic-agent tracking keys moved to MemoryKeys — the group layer reads them positionally out of a serialized snapshot, so both sides now name one constant instead of two string literals.

⚠️ Behaviour changes, deliberate:

  • A group whose policy cannot be read now gets no dynamic-agent capabilities instead of all of them.
  • An agent in a group with enableBuiltInTools=true and no whitelist now receives the dynamic-agent tools its group policy permits.

Testing

  • 869 tests green across GroupConversationService*, GroupLifecycleOps*, LlmTask*, ConversationHitl*, ConversationToolResume*; 487 across the orchestrator/tool suites.
  • 25 new tests in DynamicAgentGuardrailResolutionTest and DynamicAgentWhitelistAndCapTest, plus 3 added to RecruitAgentToolTest.

Summary by CodeRabbit

  • New Features

    • Added safer dynamic-agent guardrails for group discussions.
    • Enforced discussion-wide creation limits and prevented duplicate recruitment, including configured members who have not participated.
    • Added policy-aware tool availability when whitelists are omitted.
    • Improved lifecycle tracking, concurrent updates, teardown handling, and cleanup.
  • Bug Fixes

    • Policies now fail closed when they cannot be safely resolved.
    • Preserved existing agent display names during recruitment.
    • Failed deletions no longer remove agents from cleanup tracking.
  • Documentation

    • Added a changelog entry covering the updated guardrails and behavior.

…, per-member caps, duplicate recruits, V7

Wave B of the Agent / Group Agent review. These guard the
highest-blast-radius capability in the product -- an LLM deploying agents
to production -- and were the weakest-enforced things in the system.

1. CRITICAL: a group's dynamicAgents policy silently reverted to fully
   permissive on a resumed member turn. resolveDynamicAgentConfig accepted
   only a TYPED DynamicAgentConfig out of the context value, but a Context
   whose value round-trips through the conversation store comes back as a
   raw LinkedHashMap. Any turn against a reloaded step therefore missed the
   instanceof and fell through to the permissive standalone default --
   creation, recruitment and delegation all ON for a group that may have
   disabled every one. The trigger is ordinary: a member's gated tool call
   is auto-rejected by tryResolveMemberToolPause, which resumes the member
   conversation, and Conversation#resume re-enters the same LlmTask at the
   same index against memory freshly loaded from the store. Resolution is
   now three-state and fails closed -- absent means standalone, present and
   readable (typed or map) means the group's policy, present but unreadable
   means every capability off.

2. maxCreatedAgentsPerDiscussion was enforced per member conversation.
   seedCreatedAgentIds always read a dynamicCreatedAgentIds context
   variable for the discussion-wide total, but nothing wrote it, so a
   5-member group with the default cap of 5 could deploy 25 agents to
   production. MemberTurnExecutor now injects gc.getCreatedAgentIds().

3. RecruitAgentTool could re-recruit a configured member. isAlreadyMember
   checked memberConversationIds, which holds an agent only once it has
   SPOKEN, so a member whose first turn had not come up could be recruited
   as a duplicate -- consuming the cap, writing a misleading transcript
   entry, and overwriting the operator-chosen display name with a raw agent
   id. The tool now receives the configured roster, and display-name
   recording is putIfAbsent.

4. Teardown never freed a creation slot, and a failed delete orphaned the
   agent. createdAgentIds.remove ran before the delete, and the removal was
   from a per-turn list rebuilt from every earlier step. Teardown now
   records into dynamic:torn_down_agent_ids, which the seed subtracts and
   propagateDynamicAgentTracking applies to the group's tracking.

5. V7 resolved: an omitted builtInToolsWhitelist no longer skips the
   dynamic-agent tools. docs/langchain.md states twice that omitting the
   whitelist enables all built-in tools. Deliberately narrower in one
   respect -- the omitted case is honoured only under a governing group
   policy, because dynamicAgents is a group-config field and a standalone
   conversation has no surface on which an operator could have declined.

The three tracking keys moved to MemoryKeys so both sides name one constant.

Behaviour changes, deliberate: a group whose policy cannot be read now gets
no dynamic-agent capabilities instead of all of them; an agent in a group
with enableBuiltInTools=true and no whitelist now receives the tools its
group policy permits.

869 + 487 existing tests green; 25 new.
@ginccc
ginccc requested a review from rolandpickl as a code owner August 9, 2026 10:38
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

⚠️ Deprecation Warning: The deny-licenses option is deprecated for possible removal in the next major release. For more information, see issue 997.

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 052067da-efa0-4c14-842c-6847efbcaade

📥 Commits

Reviewing files that changed from the base of the PR and between 9d2ad95 and 97ee727.

📒 Files selected for processing (1)
  • docs/changelog.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/changelog.md

📝 Walkthrough

Walkthrough

Dynamic-agent guardrails now resolve group policies with fail-closed behavior, enforce discussion-wide creation limits, prevent duplicate recruitment, preserve configured display names, and track successful teardowns across conversation and group lifecycle operations.

Changes

Dynamic-agent guardrails

Layer / File(s) Summary
Tracking context and lifecycle merge
src/main/java/ai/labs/eddi/engine/memory/MemoryKeys.java, src/main/java/ai/labs/eddi/engine/internal/groups/..., src/main/java/ai/labs/eddi/configs/groups/model/GroupConversation.java, src/test/java/ai/labs/eddi/engine/internal/groups/...
Shared keys and member context carry dynamic-agent IDs. Lifecycle merges record tombstones before additions and preserve concurrent display-name behavior.
Policy resolution and tool wiring
src/main/java/ai/labs/eddi/modules/llm/impl/..., src/test/java/ai/labs/eddi/modules/llm/impl/...
Dynamic tools resolve typed and stored group policies. Unreadable policies disable dynamic capabilities. Omitted whitelists apply only with group policy context.
Recruitment and teardown lifecycle
src/main/java/ai/labs/eddi/modules/llm/tools/..., src/test/java/ai/labs/eddi/modules/llm/tools/...
Recruitment rejects configured members and preserves existing names. Successful teardown removes tracking and records torn-down IDs. Permanent deletion clears tracking only after deletion succeeds.
Release documentation
docs/changelog.md
The V7 changelog records the dynamic-agent guardrail and lifecycle changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AgentOrchestrator
  participant DynamicAgentToolsProvider
  participant IAgentGroupStore
  participant RecruitAgentTool
  participant TeardownAgentTool
  AgentOrchestrator->>DynamicAgentToolsProvider: provide whitelist and group store
  DynamicAgentToolsProvider->>IAgentGroupStore: resolve policy and configured members
  IAgentGroupStore-->>DynamicAgentToolsProvider: return policy and roster
  DynamicAgentToolsProvider->>RecruitAgentTool: provide roster IDs
  RecruitAgentTool-->>DynamicAgentToolsProvider: allow or reject recruitment
  DynamicAgentToolsProvider->>TeardownAgentTool: provide lifecycle tracking
  TeardownAgentTool-->>DynamicAgentToolsProvider: record successful teardown
Loading

Possibly related PRs

  • labsai/EDDI#611: Modifies dynamic-agent teardown handling and related constructor dependencies.
  • labsai/EDDI#626: Modifies dynamic-agent orchestration, lifecycle tracking, and group execution components.
  • labsai/EDDI#654: Changes TeardownAgentTool undeployment and teardown result handling.

Suggested reviewers: rolandpickl, aisabella-ai

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.49% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the dynamic-agent guardrail fixes and names several real changes, although the cap wording is less precise than the discussion-wide implementation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/dynamic-agent-guardrails

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (2)
src/test/java/ai/labs/eddi/modules/llm/tools/DynamicAgentToolsTest.java (1)

944-945: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Make the new teardown-tracking argument observable.

The ConcurrentHashMap.newKeySet() at Lines [944-945] is discarded. These tests cannot detect whether TeardownAgentTool records successful teardowns or leaves failed deletions retryable.

Store the set as tornDownAgentIds, pass it to the tool, and assert the set after the successful teardown path and after teardownAgent_deleteFailure.

Proposed fixture adjustment
         private List<String> createdAgentIds;
         private Set<String> retainedAgentIds;
+        private Set<String> tornDownAgentIds;
         private TeardownAgentTool tool;

...
             createdAgentIds = new CopyOnWriteArrayList<>(List.of("created-1", "created-2"));
             retainedAgentIds = ConcurrentHashMap.newKeySet();
+            tornDownAgentIds = ConcurrentHashMap.newKeySet();
             tool = new TeardownAgentTool(agentFactory, agentStore, deploymentStore, createdAgentIds, retainedAgentIds,
-                    ConcurrentHashMap.newKeySet());
+                    tornDownAgentIds);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/java/ai/labs/eddi/modules/llm/tools/DynamicAgentToolsTest.java`
around lines 944 - 945, Update the DynamicAgentToolsTest fixture around
TeardownAgentTool construction to store the ConcurrentHashMap.newKeySet() in a
tornDownAgentIds field or local, then pass that same set to the tool. Add
assertions verifying the expected contents after the successful teardown path
and after teardownAgent_deleteFailure, including that failed deletions remain
retryable.
src/test/java/ai/labs/eddi/modules/llm/tools/RecruitAgentToolTest.java (1)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make this test exercise addMemberDisplayNameIfAbsent.

configuredMemberIds.add(TARGET) makes recruitAgent return at Line [123] before the changed display-name method runs. The test therefore passes with the old unconditional put and does not verify the new behavior.

Use an unconfigured target with a pre-existing "Alice" mapping, assert that recruitment succeeds, and then assert that the mapping remains "Alice".

Proposed test adjustment
-    void recruit_aConfiguredMember_doesNotClobberItsDisplayName() {
-        configuredMemberIds.add(TARGET);
+    void recruit_withExistingDisplayName_doesNotClobberIt() {
         gc.addMemberDisplayName(TARGET, "Alice");

-        tool().recruitAgent(TARGET, "SecurityReviewer", "we need review");
+        String result = tool().recruitAgent(TARGET, "SecurityReviewer", "we need review");
+        assertTrue(result.startsWith("Recruited"), result);

         assertEquals("Alice", gc.getMemberDisplayNames().get(TARGET),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/java/ai/labs/eddi/modules/llm/tools/RecruitAgentToolTest.java`
around lines 294 - 303, Update
recruit_aConfiguredMember_doesNotClobberItsDisplayName to remove
configuredMemberIds.add(TARGET), keeping the pre-existing "Alice" display-name
mapping. Assert that tool().recruitAgent(TARGET, "SecurityReviewer", "we need
review") succeeds, then verify the mapping remains "Alice" so the test exercises
addMemberDisplayNameIfAbsent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main/java/ai/labs/eddi/configs/groups/model/GroupConversation.java`:
- Around line 1204-1216: Update setMemberDisplayNames to always install a
ConcurrentHashMap, including when the provided input is null and when copying
non-null entries. Preserve the existing member display names while maintaining
the concurrent-map invariant required by addMemberDisplayNameIfAbsent and
concurrent persistence iteration.

In `@src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java`:
- Around line 742-746: The created-agent merge in the dynamic ID propagation
logic must be atomic to prevent duplicate IDs from concurrent callbacks. Replace
the check-then-add behavior around gc.getCreatedAgentIds() with an atomic set,
or synchronize both contains/add and corresponding remove operations using the
same group-level lock so teardown reliably removes the ID.
- Around line 742-771: The group conversation tracking in the step-data
propagation logic must prevent stale DYNAMIC_CREATED_AGENT_IDS snapshots from
re-adding agents removed by teardown. Persist group-level teardown tombstones or
apply snapshots using a monotonic turn/version, and make the update thread-safe
so teardown and snapshot processing cannot race. Ensure later created-agent
snapshots ignore IDs already marked torn down while preserving valid agent
tracking.
- Around line 733-771: Update propagateDynamicAgentTracking to persist its
createdAgentIds and retainedAgentIds mutations before terminal cleanup callbacks
run. Add a guarded conversationStore.update or updateIfState using the existing
conversation identity/state safeguards, ensuring timed-out or abandoned-member
paths save tracking changes without overwriting newer conversation state.

In `@src/main/java/ai/labs/eddi/engine/internal/groups/MemberTurnExecutor.java`:
- Around line 318-333: Update the dispatch/retry flow around
MemberTurnExecutor’s dynamicCreatedAgentIds context so each attempt reads the
latest group state instead of reusing a stale InputData snapshot. Add a
synchronized or atomic group-level reservation mechanism that reserves creation
capacity before member callbacks execute and releases or reconciles it on
completion, ensuring overlapping turns cannot exceed
maxCreatedAgentsPerDiscussion. Add a concurrency test covering two overlapping
member turns with a cap of one.

In `@src/main/java/ai/labs/eddi/modules/llm/impl/DynamicAgentToolsProvider.java`:
- Around line 127-152: Update configuredMemberIds to return an explicit
unavailable result for missing, null, or unreadable roster data while preserving
a distinct successful empty roster; adjust the recruit_agent construction path
around RecruitAgentTool to omit recruitment whenever the roster is unavailable,
and pass the loaded member IDs only when available.
- Around line 298-300: Update the teardown_agent registration in
DynamicAgentToolsProvider to also require dynamicConfig.isEnabled(), so it is
not added when resolveDynamicAgentConfig applies a disabled or fail-closed
policy. Add an integration test covering an unreadable group policy with an
omitted whitelist and verify TeardownAgentTool is not exposed.

In `@src/main/java/ai/labs/eddi/modules/llm/tools/RecruitAgentTool.java`:
- Around line 83-98: Preserve whether the configured roster was successfully
loaded instead of collapsing missing or failed loads into an empty set: update
DynamicAgentToolsProvider.configuredMemberIds(...) and RecruitAgentTool
construction to carry an explicit unavailable state alongside
configuredMemberIds. In RecruitAgentTool.isAlreadyMember or the recruitment
entry point, refuse recruitment for live groups while the roster is unavailable,
while retaining normal empty-roster behavior after a successful read. Add a test
covering roster-loading failure and verifying recruitment is rejected.

---

Nitpick comments:
In `@src/test/java/ai/labs/eddi/modules/llm/tools/DynamicAgentToolsTest.java`:
- Around line 944-945: Update the DynamicAgentToolsTest fixture around
TeardownAgentTool construction to store the ConcurrentHashMap.newKeySet() in a
tornDownAgentIds field or local, then pass that same set to the tool. Add
assertions verifying the expected contents after the successful teardown path
and after teardownAgent_deleteFailure, including that failed deletions remain
retryable.

In `@src/test/java/ai/labs/eddi/modules/llm/tools/RecruitAgentToolTest.java`:
- Around line 294-303: Update
recruit_aConfiguredMember_doesNotClobberItsDisplayName to remove
configuredMemberIds.add(TARGET), keeping the pre-existing "Alice" display-name
mapping. Assert that tool().recruitAgent(TARGET, "SecurityReviewer", "we need
review") succeeds, then verify the mapping remains "Alice" so the test exercises
addMemberDisplayNameIfAbsent.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e714038a-d179-4933-b59c-df15157a878d

📥 Commits

Reviewing files that changed from the base of the PR and between d5294a6 and c1811c4.

📒 Files selected for processing (14)
  • docs/changelog.md
  • src/main/java/ai/labs/eddi/configs/groups/model/GroupConversation.java
  • src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java
  • src/main/java/ai/labs/eddi/engine/internal/groups/MemberTurnExecutor.java
  • src/main/java/ai/labs/eddi/engine/memory/MemoryKeys.java
  • src/main/java/ai/labs/eddi/modules/llm/impl/AgentOrchestrator.java
  • src/main/java/ai/labs/eddi/modules/llm/impl/DynamicAgentToolsProvider.java
  • src/main/java/ai/labs/eddi/modules/llm/tools/RecruitAgentTool.java
  • src/main/java/ai/labs/eddi/modules/llm/tools/TeardownAgentTool.java
  • src/test/java/ai/labs/eddi/modules/llm/impl/DynamicAgentGuardrailResolutionTest.java
  • src/test/java/ai/labs/eddi/modules/llm/impl/DynamicAgentToolsProviderTest.java
  • src/test/java/ai/labs/eddi/modules/llm/impl/DynamicAgentWhitelistAndCapTest.java
  • src/test/java/ai/labs/eddi/modules/llm/tools/DynamicAgentToolsTest.java
  • src/test/java/ai/labs/eddi/modules/llm/tools/RecruitAgentToolTest.java

Comment on lines +733 to +771
// Teardowns are applied AFTER the additions below, not interleaved with them:
// the same step carries both the cumulative created list (which still names an
// agent torn down this turn) and the teardown record, and iteration order over
// step data is not something this should depend on.
Set<String> tornDown = new LinkedHashSet<>();
for (var stepData : lastStep.getConversationStep()) {
if (stepData == null || stepData.getKey() == null) {
continue;
}
if ("dynamic:created_agent_ids"
.equals(stepData.getKey()) && stepData.getValue() instanceof java.util.Collection<?> ids) {
if (MemoryKeys.DYNAMIC_CREATED_AGENT_IDS.equals(stepData.getKey()) && stepData.getValue() instanceof Collection<?> ids) {
for (Object id : ids) {
if (id instanceof String agentId && !gc.getCreatedAgentIds().contains(agentId)) {
gc.getCreatedAgentIds().add(agentId);
LOGGER.debugf("[DYNAMIC] Propagated created agent '%s' to group conversation", agentId);
}
}
} else if ("dynamic:retained_agent_ids"
.equals(stepData.getKey()) && stepData.getValue() instanceof java.util.Collection<?> ids) {
} else if (MemoryKeys.DYNAMIC_RETAINED_AGENT_IDS.equals(stepData.getKey()) && stepData.getValue() instanceof Collection<?> ids) {
for (Object id : ids) {
if (id instanceof String agentId) {
gc.getRetainedAgentIds().add(agentId);
}
}
} else if (MemoryKeys.DYNAMIC_TORN_DOWN_AGENT_IDS.equals(stepData.getKey()) && stepData.getValue() instanceof Collection<?> ids) {
for (Object id : ids) {
if (id instanceof String agentId) {
tornDown.add(agentId);
}
}
}
}
// A torn-down agent is gone: drop it from the group's tracking so the
// discussion-wide created total injected into the NEXT member turn does not
// keep it occupying a maxCreatedAgentsPerDiscussion slot, and so ephemeral
// cleanup does not try to undeploy and delete something already deleted.
for (String agentId : tornDown) {
if (gc.getCreatedAgentIds().remove(agentId)) {
LOGGER.debugf("[DYNAMIC] Dropped torn-down agent '%s' from group conversation tracking", agentId);
}
gc.getRetainedAgentIds().remove(agentId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 12 \
  'propagateDynamicAgentTracking|cleanupEphemeralAgents|conversationStore\.(update|updateIfState)|executeDiscussion' \
  src/main/java/ai/labs/eddi/engine/internal/groups --glob '*.java'

Repository: labsai/EDDI

Length of output: 50368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and outline the key files without executing repository code.
fd -a 'GroupLifecycleOps\.java|MemberTurnExecutor\.java|GroupConversationStore.*\.java|GroupLifecycleOps.*\.java' src/main/java | sed 's#^\./##' | sort
echo '---'
ast-grep outline src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java --view expanded | sed -n '1,220p'
echo '---'
ast-grep outline src/main/java/ai/labs/eddi/engine/internal/groups/MemberTurnExecutor.java --view expanded | sed -n '1,240p'

Repository: labsai/EDDI

Length of output: 3758


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect focused slices around dynamic tracking propagation and member turn callback flow.
sed -n '700,790p' src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java | cat -n
echo '======'
sed -n '1,260p' src/main/java/ai/labs/eddi/engine/internal/groups/MemberTurnExecutor.java | cat -n
echo '======'
sed -n '260,520p' src/main/java/ai/labs/eddi/engine/internal/groups/MemberTurnExecutor.java | cat -n

Repository: labsai/EDDI

Length of output: 39899


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for usages of GroupConversationService.propagateDynamicAgentTracking and surrounding persistence points.
rg -n -C 18 \
  'GroupConversationService\.propagateDynamicAgentTracking|propagateDynamicAgentTracking\(|conversationStore\.(update|updateIfState)' \
  src/main/java/ai/labs/eddi/engine/internal/groups --glob '*.java'

echo '==== GroupLifecycleOps execute/fail/cleanup slice ====='
sed -n '530,718p' src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java | cat -n

echo '==== MemberTurnExecutor remaining core slice ====='
sed -n '520,820p' src/main/java/ai/labs/eddi/engine/internal/groups/MemberTurnExecutor.java | cat -n

Repository: labsai/EDDI

Length of output: 50368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect exactly where propagateDynamicAgentTracking is called and whether the GroupConversation snapshot is written to MongoDB.
rg -n -C 24 \
  'GroupConversationService\.propagateDynamicAgentTracking|propagateDynamicAgentTracking\(' src/main/java --glob '*.java'

echo '==== Store update methods and usages containing created/retained/teardown terms maybe ====='
rg -n -C 8 \
  'getCreatedAgentIds|setCreatedAgentIds|getRetainedAgentIds|setRetainedAgentIds|TORN_DOWN_DYNAMIC_AGENT|cleanupEphemeralAgents|propagateDynamicAgentTracking' src/main/java --glob '*.java'

Repository: labsai/EDDI

Length of output: 50367


Persist dynamic agent tracking before late callbacks.

propagateDynamicAgentTracking updates only the in-memory GroupConversation, and none of the call sites followed by terminal teardown writes those mutations back with conversationStore.update(...) or conversationStore.updateIfState(...). Add a guarded persisted update before terminal cleanup so a timed-out or abandoned-member callback cannot lose createdAgentIds/retainedAgentIds changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java`
around lines 733 - 771, Update propagateDynamicAgentTracking to persist its
createdAgentIds and retainedAgentIds mutations before terminal cleanup callbacks
run. Add a guarded conversationStore.update or updateIfState using the existing
conversation identity/state safeguards, ensuring timed-out or abandoned-member
paths save tracking changes without overwriting newer conversation state.

Comment thread src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java Outdated
Comment thread src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java Outdated
Comment on lines +318 to +333
// The discussion-wide created-agent total, so maxCreatedAgentsPerDiscussion
// bounds the DISCUSSION rather than each member conversation independently.
//
// DynamicAgentToolsProvider.seedCreatedAgentIds has always read this context
// key, but nothing wrote it: it could only see the ids in the member's OWN
// conversation memory, so a 5-member group with the default cap of 5 could
// deploy 25 agents to production per discussion while both the field name and
// docs/group-conversations.md promised 5. gc.getCreatedAgentIds() is the real
// total — propagateDynamicAgentTracking folds every member's creations into it
// at the end of each turn.
//
// Snapshot, not the live CopyOnWriteArrayList: this rides into another
// conversation's memory and gets serialised there.
context.put("dynamicCreatedAgentIds",
new Context(Context.ContextType.object, List.copyOf(gc.getCreatedAgentIds())));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Reserve discussion-wide creation capacity before dispatch.

List.copyOf(gc.getCreatedAgentIds()) captures only the IDs known at that moment. The same InputData is reused across retries, and other member callbacks can update gc after this snapshot is created. Parallel or late member turns can therefore evaluate maxCreatedAgentsPerDiscussion with a stale count and create too many agents.

Refresh the context for each attempt, and enforce an atomic group-level reservation for in-flight creations. Add a test with two overlapping member turns and a cap of one.

As per coding guidelines, backend code must be thread-safe.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/ai/labs/eddi/engine/internal/groups/MemberTurnExecutor.java`
around lines 318 - 333, Update the dispatch/retry flow around
MemberTurnExecutor’s dynamicCreatedAgentIds context so each attempt reads the
latest group state instead of reusing a stale InputData snapshot. Add a
synchronized or atomic group-level reservation mechanism that reserves creation
capacity before member callbacks execute and releases or reconciles it on
completion, ensuring overlapping turns cannot exceed
maxCreatedAgentsPerDiscussion. Add a concurrency test covering two overlapping
member turns with a cap of one.

Source: Coding guidelines

Comment thread src/main/java/ai/labs/eddi/modules/llm/impl/DynamicAgentToolsProvider.java Outdated
Comment thread src/main/java/ai/labs/eddi/modules/llm/impl/DynamicAgentToolsProvider.java Outdated
Comment on lines +83 to +98
/**
* The ids on the group's CONFIGURED roster. Supplied at construction because
* the tool sees only the {@link DynamicAgentConfig}, never the group config —
* which is why {@link #isAlreadyMember} could not check the roster and this
* field exists.
*/
private final Set<String> configuredMemberIds;

public RecruitAgentTool(LiveDiscussionRegistry registry, String groupConversationId, String recruiterAgentId,
DynamicAgentConfig config, IDeploymentStore deploymentStore) {
DynamicAgentConfig config, IDeploymentStore deploymentStore, Set<String> configuredMemberIds) {
this.registry = registry;
this.groupConversationId = groupConversationId;
this.recruiterAgentId = recruiterAgentId;
this.config = config;
this.deploymentStore = deploymentStore;
this.configuredMemberIds = configuredMemberIds != null ? Set.copyOf(configuredMemberIds) : Set.of();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not fail open when the configured roster cannot be read.

configuredMemberIds represents only a set. DynamicAgentToolsProvider.configuredMemberIds(...) returns Set.of() when the group resource is missing or roster loading fails. The tool cannot distinguish an empty roster from an unavailable roster.

For a live group, a deployed configured member that has not spoken can then pass isAlreadyMember, consume a recruitment slot, and enter the dynamic roster as a duplicate. Preserve an explicit unavailable state and refuse recruitment until the roster is read successfully. Add a failure-path test.

Also applies to: 179-191

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/ai/labs/eddi/modules/llm/tools/RecruitAgentTool.java` around
lines 83 - 98, Preserve whether the configured roster was successfully loaded
instead of collapsing missing or failed loads into an empty set: update
DynamicAgentToolsProvider.configuredMemberIds(...) and RecruitAgentTool
construction to carry an explicit unavailable state alongside
configuredMemberIds. In RecruitAgentTool.isAlreadyMember or the recruitment
entry point, refuse recruitment for live groups while the roster is unavailable,
while retaining normal empty-roster behavior after a successful read. Add a test
covering roster-loading failure and verifying recruitment is rejected.

…down tombstone, atomic merge

Six of seven CodeRabbit findings on #649; the seventh is declined with
reasoning below.

Major, security -- teardown_agent was not gated on the policy. Its
assembly branch checked only that the stores were present, and the tool
itself takes no DynamicAgentConfig, so a group whose policy is disabled --
or unreadable, which now resolves fail-closed -- could still undeploy and
PERMANENTLY DELETE a tracked agent. A hole the V7 change widened, since an
omitted whitelist now reaches this branch. Gated on
dynamicConfig.isEnabled().

Major -- an unreadable roster failed open. configuredMemberIds returned an
empty set for both "no members" and "could not read", so a store hiccup
silently restored the duplicate-recruit defect the change exists to
prevent. It now returns Optional, and an unavailable roster withholds
recruit_agent for that turn -- gate by absence, matching
ArtifactToolsProvider.

Major -- stale snapshots could resurrect a torn-down agent. Each member's
tracking snapshot is one member's view: member B's turn can still name an
agent member A tore down in between, and the merge re-added it.
GroupConversation now carries a tornDownAgentIds tombstone, written before
the merge by recordTeardown and consulted by it, so a teardown is final
regardless of arrival order.

Major -- the created-agent merge was not atomic. CopyOnWriteArrayList
makes each add atomic but not contains()-then-add(), and merges run on one
coordinator thread per member turn; two could both append the same id,
after which the single remove() a teardown performs leaves a duplicate.
The compound operation now runs under the list's monitor, the same pattern
RecruitAgentTool uses for recruitedAgentIds.

Major -- setMemberDisplayNames installed a LinkedHashMap, so every reload
dropped the concurrency guarantee the field declares, on a map written
from member-turn threads and iterated by serialization. Now
ConcurrentHashMap on both branches.

Declined -- atomic reservation of creation capacity before dispatch.
Correct that parallel member turns can each read a stale count and
collectively overshoot. That is the same accepted-overshoot shape this
codebase already documents for the cost ceiling ("an in-flight turn may
still push the total past the ceiling; that overshoot is accepted, not
prevented" -- GroupCostLedger), and closing it properly needs budget
RESERVATION at dispatch, which is a design change rather than a fix. The
cap moves from unbounded-per-member to bounded-with-parallel-overshoot;
the residue is recorded rather than silently fixed.

1107 unit tests green; 7 new, the tombstone guard mutation-verified.
@ginccc

ginccc commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

Thanks @coderabbitai — six of seven fixed, one declined with reasoning.

Fixed

  1. teardown_agent not gated on the policy (security). Correct, and it was a hole this PR widened: with V7 the omitted-whitelist path now reaches that branch, and the branch only checked that the stores were non-null while the tool itself carries no DynamicAgentConfig. A disabled — or unreadable, hence fail-closed — policy could still undeploy and permanently delete. Now gated on dynamicConfig.isEnabled().
  2. Unreadable roster failed open. You're right, and I'd argued myself into the wrong default: failing open silently restores the exact defect the roster check exists to prevent. configuredMemberIds now returns Optional; Optional.empty() means unavailable (distinct from a successfully-read empty roster) and withholds recruit_agent for that turn. Same gate-by-absence discipline as ArtifactToolsProvider. This covers both your comments on this point (provider side and tool side).
  3. Stale snapshots resurrecting a torn-down agent. Real, and cheaper to fix than "heavy lift" suggested — the tombstone only has to live on the GroupConversation, which already carries retainedAgentIds in exactly that shape. New tornDownAgentIds, written before the merge by recordTeardown and consulted by the merge, so ordering no longer decides the outcome.
  4. Non-atomic created-agent merge. Correct. Compound op now runs under the list's monitor — the same pattern RecruitAgentTool already uses for recruitedAgentIds, rather than introducing a new lock discipline.
  5. setMemberDisplayNames installing a LinkedHashMap. Correct and worse than the PR: it meant every reload dropped the guarantee the field declares. Now ConcurrentHashMap on both branches.

Declined — atomic reservation of creation capacity before dispatch

The analysis is right: parallel member turns each read a stale count and can collectively overshoot. But that is the same accepted-overshoot shape this codebase already documents for the cost ceiling — GroupCostLedger: "an already-dispatched, in-flight turn may still push the total past the ceiling; that overshoot is accepted, not prevented" — and MemberTurnExecutor documents the identical bound for nested-group budgets. Closing it properly needs budget reservation at dispatch (each turn claiming a slice up front, plus a decision about returning unspent slices), which is a design change, not a fix, and one that should apply to cost and creation together rather than to one of them here.

What the PR does deliver is the change from unbounded per member (5 members × cap 5 = 25 agents against a documented cap of 5) to bounded with a parallel overshoot. The residue is recorded in the changelog rather than silently left.

7 new tests in DynamicAgentTrackingMergeTest (tombstone, ordering-independence, a 16-thread concurrent-merge check, and the reload/display-name invariant); the tombstone guard is mutation-verified. 1107 unit tests green.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@ginccc Thank you for the detailed update.

I acknowledge the six fixes. The policy gate and the fail-closed roster behavior close the two security-sensitive paths. The tombstone, synchronized merge, and ConcurrentHashMap fixes also address the state-consistency issues.

I accept the scope decision for atomic creation-capacity reservation. The remaining parallel overshoot is bounded, documented, and consistent with the existing GroupCostLedger and nested-group budget model. A reservation design should apply consistently to related budgets.

The new DynamicAgentTrackingMergeTest coverage and the 1107 passing unit tests provide good verification.

ᕱ⑅ᕱ


✏️ Learnings added
Learnt from: ginccc
URL: https://github.com/labsai/EDDI/pull/649

Timestamp: 2026-08-09T11:23:54.148Z
Learning: In the Java group-conversation engine, a parallel member turn can overshoot a group-wide budget after dispatch. This bounded in-flight overshoot is an accepted and documented behavior for `GroupCostLedger` cost ceilings and `MemberTurnExecutor` nested-group budgets. Apply a budget-reservation design consistently across cost and dynamic-agent creation capacity if strict pre-dispatch enforcement is required.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/changelog.md`:
- Around line 24-32: Update the Review round 1 entry so its stated total and
fixed/declined counts match the listed findings: six fixed findings and one
declined finding, for seven total. Keep all listed bullets and the 25-test count
unchanged, and make the summary line consistent with those counts.

In `@src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java`:
- Around line 765-794: Make the teardown and created/retained tracking merge
atomic by introducing one private tracking mutex in the group conversation
tracking state and using it for tombstone insertion, live-list removals, and
created/retained admissions in the merge flow around recordTeardown and the
created/retained loops. Ensure every read-check-write against these collections
uses the same mutex so stale snapshots cannot re-add torn-down IDs. Add a
latching regression test that concurrently races teardown with stale created and
retained snapshots and verifies the IDs remain removed.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 440eec22-3789-427f-88b5-94f1e86e9fc3

📥 Commits

Reviewing files that changed from the base of the PR and between c1811c4 and c49dc37.

📒 Files selected for processing (6)
  • docs/changelog.md
  • src/main/java/ai/labs/eddi/configs/groups/model/GroupConversation.java
  • src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java
  • src/main/java/ai/labs/eddi/modules/llm/impl/DynamicAgentToolsProvider.java
  • src/main/java/ai/labs/eddi/modules/llm/tools/TeardownAgentTool.java
  • src/test/java/ai/labs/eddi/engine/internal/groups/DynamicAgentTrackingMergeTest.java
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/main/java/ai/labs/eddi/modules/llm/tools/TeardownAgentTool.java
  • src/main/java/ai/labs/eddi/modules/llm/impl/DynamicAgentToolsProvider.java

Comment thread docs/changelog.md
Comment thread src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java Outdated
…monitor

CodeRabbit is right that round 1 did not close the race. Ordering the
writes inside recordTeardown is not sufficient on its own: a merge can read
the tombstone set, find the id absent, be descheduled while a concurrent
teardown records the tombstone and removes the id, and then complete its
own add -- putting back an agent that no longer exists. The retained-agent
branch had the same shape and sat outside the monitor entirely.

Check-tombstone-then-add and record-teardown are now mutually exclusive on
a shared dynamicTrackingMutex on GroupConversation (transient +
@JsonIgnore, the same pattern as artifactAnnounceMutex), and the merge's
synchronized region now covers the retained branch as well as the created
one.

Two 200-round interleaving tests pin it, both mutation-verified: dropping
the synchronized block from recordTeardown fails them.

228 tests green across the affected suites.
@ginccc

ginccc commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

Round 2 — @coderabbitai you're right, and thanks for re-checking rather than accepting round 1.

Fixed: teardown and tracking merges now share one monitor. Ordering the writes inside recordTeardown was not sufficient on its own — I'd closed the write-ordering half and left the check-then-act half open. A merge could read the tombstone set, find the id absent, be descheduled while a concurrent teardown recorded it, and then complete its own add. And you're right that the retained branch had the same shape and sat outside the monitor entirely.

Both now run under a shared dynamicTrackingMutex on GroupConversation (transient + @JsonIgnore, the same pattern the class already uses for artifactAnnounceMutex), so check-tombstone-then-add and record-teardown are mutually exclusive rather than merely internally ordered. The merge's synchronized region now covers the retained branch too.

Pinned by two 200-round interleaving tests that race a teardown against a merge on the same id — one for createdAgentIds, one for retainedAgentIds. Both mutation-verified: dropping the synchronized block from recordTeardown fails them.

The changelog comment appears to have resolved to an analysis chain with no finding attached — I've made the two test counts in that entry unambiguous (869 for the group-service suites, 228 for the merge-affected suites in this round) in case that was the point. Happy to address it directly if there was something more specific.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/main/java/ai/labs/eddi/configs/groups/model/GroupConversation.java --items all --match 'GroupConversation|recordTeardown|dynamicTrackingMutex' --view expanded
ast-grep outline src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java --items all --match 'dynamic|merge|created|retained|torn' --view expanded
ast-grep outline src/test/java/ai/labs/eddi/engine/internal/groups/DynamicAgentTrackingMergeTest.java --items all --view expanded

printf '\n--- mutex and merge references ---\n'
rg -n -C 8 'dynamicTrackingMutex|recordTeardown|tornDownAgentIds|createdAgentIds|retainedAgentIds' \
  src/main/java/ai/labs/eddi/configs/groups/model/GroupConversation.java \
  src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java

printf '\n--- interleaving test references ---\n'
rg -n -C 8 '200|round|interleav|teardown|createdAgentIds|retainedAgentIds|Executor|thread' \
  src/test/java/ai/labs/eddi/engine/internal/groups/DynamicAgentTrackingMergeTest.java

Length of output: 41555


@ginccc Confirmed. recordTeardown and propagateDynamicAgentTracking synchronize on the same dynamicTrackingMutex.

The synchronized merge covers both createdAgentIds and retainedAgentIds. It protects the tombstone check and the subsequent add. This closes the teardown-versus-merge check-then-add race.

I also verified both 200-round interleaving tests in DynamicAgentTrackingMergeTest. They cover created-agent and retained-agent tracking.

The changelog test-count clarification is sufficient. I have no further finding on that entry.

ᕱᕱ


✏️ Learnings added
Learnt from: ginccc
URL: https://github.com/labsai/EDDI/pull/649

Timestamp: 2026-08-09T11:45:48.334Z
Learning: In `src/main/java/ai/labs/eddi/configs/groups/model/GroupConversation.java` and `src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java`, dynamic-agent teardown and tracking merges must synchronize on the shared `GroupConversation.dynamicTrackingMutex`. The lock must cover the tombstone check and additions to both `createdAgentIds` and `retainedAgentIds`; ordering writes inside `recordTeardown(String)` alone does not prevent a stale merge from re-adding a torn-down agent.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/test/java/ai/labs/eddi/engine/internal/groups/DynamicAgentTrackingMergeTest.java (1)

155-168: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Strengthen the reload test so it can detect a map-type regression.

The test name states that the map stays concurrent, but the assertions do not verify that. Both assertions pass on a LinkedHashMap, because uncontended putIfAbsent behaves the same. If setMemberDisplayNames regresses to LinkedHashMap, this test stays green — and that regression is the defect the fix at Line 1275 addresses.

Add contention so the concurrent-map invariant is actually exercised. The file already uses this latch pattern.

💚 Proposed strengthening
     `@Test`
     `@DisplayName`("member display names survive a store reload as a concurrent map")
-    void displayNamesStayConcurrentAfterReload() {
+    void displayNamesStayConcurrentAfterReload() throws Exception {
         var gc = conversation();
         // What deserialization does.
         gc.setMemberDisplayNames(Map.of("agent-1", "Alice"));
 
         gc.addMemberDisplayNameIfAbsent("agent-1", "agent-1");
         gc.addMemberDisplayNameIfAbsent("agent-2", "agent-2");
 
         assertEquals("Alice", gc.getMemberDisplayNames().get("agent-1"),
                 "putIfAbsent must be atomic here, and must not overwrite an operator-chosen name");
         assertEquals("agent-2", gc.getMemberDisplayNames().get("agent-2"));
+
+        // A LinkedHashMap passes everything above; only contention distinguishes
+        // the two. Concurrent writers plus an unguarded reader is exactly what
+        // RecruitAgentTool and serialization do to this map.
+        int writers = 16;
+        var start = new CountDownLatch(1);
+        var done = new CountDownLatch(writers);
+        for (int i = 0; i < writers; i++) {
+            int n = i;
+            Thread.ofVirtual().start(() -> {
+                try {
+                    start.await();
+                    gc.addMemberDisplayNameIfAbsent("recruit-" + n, "recruit-" + n);
+                    gc.getMemberDisplayNames().forEach((k, v) -> {
+                    });
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                } finally {
+                    done.countDown();
+                }
+            });
+        }
+        start.countDown();
+        assertTrue(done.await(10, TimeUnit.SECONDS), "display-name writes deadlocked");
+        assertEquals(writers + 2, gc.getMemberDisplayNames().size());
+        assertEquals("Alice", gc.getMemberDisplayNames().get("agent-1"));
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/java/ai/labs/eddi/engine/internal/groups/DynamicAgentTrackingMergeTest.java`
around lines 155 - 168, Strengthen displayNamesStayConcurrentAfterReload by
adding the file’s existing latch-based concurrent contention pattern around
addMemberDisplayNameIfAbsent, using multiple threads and joining them before
assertions. Ensure the test would fail or expose a regression where
setMemberDisplayNames creates a non-concurrent map, while preserving the
assertion that the pre-existing “Alice” value is never overwritten.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@src/test/java/ai/labs/eddi/engine/internal/groups/DynamicAgentTrackingMergeTest.java`:
- Around line 155-168: Strengthen displayNamesStayConcurrentAfterReload by
adding the file’s existing latch-based concurrent contention pattern around
addMemberDisplayNameIfAbsent, using multiple threads and joining them before
assertions. Ensure the test would fail or expose a regression where
setMemberDisplayNames creates a non-concurrent map, while preserving the
assertion that the pre-existing “Alice” value is never overwritten.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d877655-ee77-4993-bbb1-d987cbb7e19a

📥 Commits

Reviewing files that changed from the base of the PR and between c49dc37 and ed093d6.

📒 Files selected for processing (4)
  • docs/changelog.md
  • src/main/java/ai/labs/eddi/configs/groups/model/GroupConversation.java
  • src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java
  • src/test/java/ai/labs/eddi/engine/internal/groups/DynamicAgentTrackingMergeTest.java
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java
  • docs/changelog.md

One documentation-discipline finding from a model-independent second pass:
tornDownAgentIds is a persisted, resume-consumed field, and the
schema-version comment's own rule says such fields bump the version. It
deliberately rides v4 instead, and the comment now records why, so the
decision reads as decided rather than missed:

- It fails soft in every skew direction. A legacy document defaults to an
  empty set (identity hop, no migration entry), and an older pod re-saving
  a paused document drops the tombstones -- after which the worst outcome
  is a torn-down agent re-occupying a maxCreatedAgentsPerDiscussion slot
  and terminal cleanup retrying a deletion that 404s harmlessly.
- Contrast runtimePhases, where the same skew mis-indexes resume
  bookmarks: corruption, not conservatism. A version bump signals "an old
  pod must not touch this document"; this field does not earn that.

The second pass also re-verified the V7 wiring end to end:
collectEnabledTools has no production caller, the live path is
buildToolSetup -> contribute(), and both paths carry the new semantics --
no double-add.

68 tests green incl. GroupConversationSchemaMigrationsTest.
@ginccc

ginccc commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

Final review pass (independent second-model review of the full diff) — one documentation-discipline finding, fixed in the latest commit, plus two verifications worth recording:

  • tornDownAgentIds rides schema v4 against the version comment's own rule ("bump whenever a Wave adds a resume-consumed field"). That's now a recorded decision rather than an omission: the field fails soft in every skew direction — a legacy document defaults to an empty set (identity hop), and an older pod re-saving a paused document drops the tombstones, after which the worst outcome is a dead agent re-occupying a cap slot and cleanup retrying a deletion that 404s harmlessly. Contrast runtimePhases, whose skew corrupts resume bookmarks. A version bump signals "an old pod must not touch this document"; this field doesn't earn that.
  • Verified: no double-add from the V7 change. DynamicAgentToolsProvider is both registered in buildToolSetup's merger and called directly from collectAllBuiltInTools — but collectEnabledTools has no production caller (kept only so AgentOrchestratorLocalToolAssemblyTest can pin that the two paths agree tool-for-tool), so exactly one path runs per turn and both now carry the same semantics.
  • Verified: the stored-map conversion round-trips LifecyclePolicy. The enum carries @JsonValue/@JsonCreator, which the bare ObjectMapper honors, so a legitimate stored policy converts cleanly and fail-closed is reserved for genuinely unreadable ones. Failing closed on unknown fields (a doc written by a newer version) is also the right direction: ignoring them could drop a future restricting field.

@ginccc
ginccc merged commit 49ef428 into main Aug 10, 2026
23 checks passed
pull Bot pushed a commit to Stars1233/EDDI that referenced this pull request Aug 10, 2026
…not yet supported" section

Wave E of the Agent / Group Agent review -- the documentation drifts the
review turned up, each one a place where docs and code disagreed.

1. Attachments x groups were entirely undocumented. group-conversations.md
   had zero mentions of attachments and attachments-guide.md zero mentions
   of groups, while POST /groups/{groupId}/conversations accepts them and
   GroupAttachmentBinder is a whole subsystem. Both files now cover the
   three input shapes, the first-turn grant, how later phases keep access
   (history plus the auto-enabled readAttachment tool), and the two
   group-specific bounds: the per-turn cap applies per MEMBER turn, and
   anything dropped is reported in that member's attachments:errors, not
   in the group transcript.

2. The protocol table did not say the defaults apply to an absent block.
   Nothing backfills a stored config, so "no protocol block" is the common
   shape -- which is what made the 60-vs-180 drift fixed in labsai#648
   invisible.

3. maxCreatedAgentsPerDiscussion now reads "counted across all members,
   not per member" -- the behaviour labsai#649 delivers.

4. LAST_PHASE was documented as "only the previous phase's entries", but
   the filter is phaseIndex >= currentPhaseIdx - 1, which includes the
   running phase. The code is right -- in a sequential phase that is what
   lets the second speaker react to the first -- so the doc and the enum
   Javadoc were corrected, not the filter.

5. New "Not yet supported" section: member-level tool approval inside a
   group, nested pauses, groups over the OpenAI-compatible /v1 adapter,
   groups over A2A, and the per-node scope of the live-discussion
   registry.

Also: an FQN sweep of CreateSubAgentTool (11 inline fully-qualified names,
against AGENTS.md 4.7) and the orphaned Javadoc in LiveDiscussionRegistry,
where the paragraph documenting get() sat above getForMember() so both
attached to the latter and get() had none.

Scoping: the FQN violation is repo-wide (~130 sites). This sweeps only
files no other open PR touches; the rest is a follow-up once labsai#648-labsai#651
land.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants