diff --git a/AGENTS.md b/AGENTS.md index 90a96d8f6..d6016bdbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ Follow this order unless the user explicitly requests something different. | 7 | Security & Compliance | Secrets Vault, Audit Ledger (EU AI Act), tenant quota stub | | 8 | MCP Integration | MCP Server (33 tools), MCP Client, agent discovery, managed conversations | | 8c | RAG Foundation | Config-driven vector store retrieval, pgvector, httpCall RAG | -| 10 | Group Conversations | Multi-agent debate orchestration, 5 styles, group-of-groups | +| 10 | Group Conversations | Multi-agent debate orchestration, 6 styles (incl. Task Force), group-of-groups | | — | A2A Protocol | Agent-to-Agent peer communication, Agent Cards, skill discovery | | — | Multi-Model Cascading | Sequential model escalation with confidence routing | | — | LLM Provider Expansion | 7 → 12 providers (Mistral, Azure OpenAI, Bedrock, Oracle GenAI) | diff --git a/HANDOFF.md b/HANDOFF.md index 69d0c0765..8ec6f6665 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -555,18 +555,18 @@ All hooks are **non-fatal** — if schedule operations fail, the primary agent o ### Phase 10: Group Conversations — Multi-Agent Debate Orchestration ✅ -Phase-based orchestration engine enabling structured multi-agent discussions with 5 preset styles, nested group-of-groups support, and moderator synthesis. +Phase-based orchestration engine enabling structured multi-agent discussions with 6 preset styles, nested group-of-groups support, and moderator synthesis. | Sub-Phase | Deliverables | |---|---| | **10.1** Data Models + Stores | `AgentGroupConfiguration`, `GroupConversation`, group/conversation stores (DB-agnostic) | | **10.2** Orchestration Service | `GroupConversationService` (~680 lines) — phases, context scoping, templates | | **10.3** REST + SSE + MCP | REST endpoints, SSE streaming, `McpGroupTools` (9 tools) | -| **10.5** Discussion Styles | `DiscussionPhase`, `DiscussionStylePresets` — 5 styles + custom phases | +| **10.5** Discussion Styles | `DiscussionPhase`, `DiscussionStylePresets` — 6 styles + custom phases | | **10.5b** MCP/REST Usability | `describe_discussion_styles`, `list_group_conversations`, `GET /styles` | | **10.6** Group-of-Groups | `MemberType.GROUP`, recursive `executeGroupMemberTurn()`, `memberTypes` param | -**Discussion styles:** `ROUND_TABLE`, `PEER_REVIEW`, `DEVIL_ADVOCATE`, `DELPHI`, `DEBATE` (+ fully custom phases) +**Discussion styles:** `ROUND_TABLE`, `PEER_REVIEW`, `DEVIL_ADVOCATE`, `DELPHI`, `DEBATE`, `TASK_FORCE` (+ fully custom phases) **Group-of-Groups:** Members can be other groups — sub-group synthesized answer becomes member's response. Depth tracking prevents infinite recursion (`eddi.groups.max-depth`, default: 3). diff --git a/README.md b/README.md index cb7ffa7fa..bf5250d2a 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ Most multi-agent frameworks (LangGraph, CrewAI, AutoGen) are Python/Node librari ### 🤖 Multi-Agent Orchestration - 🔀 **Intelligent Routing** — Direct conversations to different agents based on context, rules, and intent -- 🗣️ **Group Conversations** — Multi-agent debates with 5 built-in discussion styles: Round Table, Peer Review, Devil's Advocate, Delphi, and Debate +- 🗣️ **Group Conversations** — Multi-agent debates with 6 built-in discussion styles: Round Table, Peer Review, Devil's Advocate, Delphi, Debate, and Task Force - 💬 **Slack Integration** — Deploy agents to Slack channels and run multi-agent debates directly in threads - 🪆 **Nested Groups** — Compose groups of groups for tournament brackets, red-team vs blue-team, and panel reviews - 👥 **Managed Conversations** — Intent-based auto-routing with one conversation per user per intent diff --git a/docs/architecture.md b/docs/architecture.md index 30ec82b51..0b17e92e8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -868,7 +868,7 @@ A `GroupConversationService` orchestrates discussions through configurable phase **Key capabilities:** -- **5 built-in discussion styles**: Round Table, Peer Review, Devil's Advocate, Delphi, and Debate — each with distinct phase flows and turn-taking rules +- **6 built-in discussion styles**: Round Table, Peer Review, Devil's Advocate, Delphi, Debate, and Task Force — each with distinct phase flows and turn-taking rules. Task Force uses a 4-phase pipeline (PLAN→EXECUTE→VERIFY→SYNTHESIS) for structured task decomposition and parallel execution - **Custom phases**: Define your own phase sequences with configurable context scopes (independent, full transcript, anonymous, own-feedback-only) - **Group-of-groups**: Members can themselves be groups, enabling hierarchical multi-agent composition with configurable depth limits - **Fault tolerance**: Per-agent timeouts, configurable failure policies (skip, retry, abort), and graceful degradation when members are unavailable diff --git a/docs/changelog.md b/docs/changelog.md index a07f4df69..5bb7fd182 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,10 +2,216 @@ > **Purpose:** Living document tracking all changes, decisions, and reasoning during implementation. Updated as work progresses for easy reference and review. + +--- + +## 🔒 PR Review Fixes — DynamicAgentConfig Propagation, Null Safety, Code Dedup (2026-06-26) + +**Repo:** EDDI (`feat/group-task-orchestration`) +**What changed:** 5 fixes addressing Copilot PR review findings. + +### Fixes + +1. **HIGH: DynamicAgentConfig propagation** — Group-level guardrails were silently ignored. Fix: GCS stores config on GC (transient), passes via context to AgentOrchestrator which reads it from memory. +2. **MEDIUM: Null-safe DynamicAgentConfig** — Constructor defaults null to disabled config. +3. **MEDIUM: Null-safe provider allow-list** — `Objects::nonNull` filter before `equalsIgnoreCase()`. +4. **MEDIUM: Null-safe model allow-list** — Filters for both null map values and null list entries. +5. **LOW: extractResponse() deduplication** — Shared `ConversationOutputExtractor` utility replacing 3 copies. + +### Files Changed +- `GroupConversation.java` — Transient `dynamicAgentConfig` field (`@JsonIgnore`) +- `GroupConversationService.java` — Config propagation + `extractResponse()` delegation +- `AgentOrchestrator.java` — `resolveDynamicAgentConfig()` reads group config from context +- `CreateSubAgentTool.java` — Null-safe constructor + allow-lists + `extractResponse()` delegation +- `ConverseWithAgentTool.java` — `extractResponse()` delegation +- `ConversationOutputExtractor.java` — **[NEW]** Shared utility + +### Tests Added +- `ConversationOutputExtractorTest` — 11 tests +- `DynamicAgentToolsTest` — 7 new null-safety tests + +--- + +## 🔧 MCP Group Tools — Async Discussion, Delete, @Blocking Fix (2026-06-26) + +**Repo:** EDDI (`feat/group-task-orchestration`) +**What changed:** 3 MCP improvements for Task Force group discussions. + +### Changes +- **Bug fix**: `discuss_with_group` was missing `@Blocking` — a multi-minute TASK_FORCE discussion would block the Vert.x event loop thread, potentially freezing the MCP server. Now correctly annotated (matches `talk_to_agent` pattern in McpConversationTools). +- **New tool**: `start_group_discussion` — async variant that returns immediately with `groupConversationId` + `IN_PROGRESS` state. Client polls with `read_group_conversation`. Uses existing `startAndDiscussAsync()` backend method. +- **New tool**: `delete_group_conversation` — REST-MCP parity gap. DELETE endpoint existed in REST API but had no MCP equivalent. +- **Improved docs**: Tool descriptions now document what data `read_group_conversation` returns (task list, tracking lists, state) so MCP clients know they don't need separate tools for task inspection. + +### Design Decision +Rejected adding 5 separate tools (read_task_list, list_dynamic_agents, discuss_task, clone_group, describe_task_force_syntax) — all proposed data is already available via existing tools. Avoided tool sprawl (project already has 63 MCP tools). + +### Coverage +- McpGroupTools: 91.79% instruction, 81.25% branch, 100% methods +- 9 new tests (31 total in McpGroupToolsTest): async success/defaults/blank/error, delete success/confirmation/error, @Blocking annotation reflection tests +- Full suite: 9,611 tests, 0 failures + +--- + +## 🧪 Comprehensive Branch Coverage for Dynamic Agent System (2026-06-25) + +**Repo:** EDDI (`feat/group-task-orchestration`) +**What changed:** Added 60+ targeted unit tests to cover all uncovered branches in the Task Force / Dynamic Agent feature. Coverage improved from 0.88→0.89 instructions (unit tests only; CI with integration tests will exceed 0.90/0.80 thresholds). + +### Files Modified (Tests) +- **DynamicAgentToolsTest** (+25 tests): initialMessage flow, extractResponse all branches, blank params, empty allow-lists, retain=false, general exceptions +- **TaskListParserTest** (+22 tests): all JSON key aliases, null/empty members, markdown formats, long text truncation, null displayName safety +- **SharedTaskListTest** (+12 tests): findTasksForAgent(null), wrong status transitions, nonexistent ID exceptions, failTask from various states, setTasks(null) +- **AgentGroupConfigurationTest** (+12 tests): LifecyclePolicy toJson/fromJson, TaskDefinition constructors, DiscussionPhase requiresApproval + +### Notes +- Local `mvnw verify` shows 0.89/0.78 because ITs are skipped. CI runs `-DskipITs=false` → exceeds thresholds. +- Total test count: 9,573 (0 failures, 0 errors) + +--- + +## 🔧 Dynamic Agent System — Critical Code Review Fixes (2026-06-25) + + +**Repo:** EDDI (`feat/group-task-orchestration`) +**What changed:** 3-reviewer code review uncovered 6 critical bugs and 8 medium issues. All critical and key medium issues fixed. + +### Critical Fixes +- **C1: Shared tracking lists** — `AgentOrchestrator` was creating separate `createdAgentIds`/`retainedAgentIds` per whitelist tool call. TeardownAgentTool couldn't see agents created by CreateSubAgentTool. Fixed: shared lists created once, passed to all tools. +- **C2: Retain flag non-functional** — `CreateSubAgentTool` accepted `retain=true` but never populated `retainedAgentIds`. Agents were auto-deleted despite LLM requesting retention. Fixed: wired `Set retainedAgentIds` to constructor + `retainedAgentIds.add(agentId)` when retain=true. +- **C3: Double quota counting** — `CreateSubAgentTool` called `acquireConversationSlot()` then `startConversation()` also called it internally. Each creation burned 2 quota slots. Fixed: removed explicit quota call from tool. +- **C4: Transcript race condition** — `GroupConversation.transcript` was a plain `ArrayList` accessed from parallel virtual threads. Fixed: `Collections.synchronizedList(new ArrayList<>())` + null-safe setter. +- **C5: Dead ERROR detection** — `ConverseWithAgentTool.extractResponse()` returned `""` instead of `null`, making `response == null` check dead code. Fixed: returns `null` for empty/missing outputs. +- **C6: Zero test coverage** — `ConverseWithAgentTool` had 154 lines of untested code. Added 8 tests covering new conversation, existing conversation, validation, timeout, error state, empty response. + +### Medium Fixes +- **M1: LifecyclePolicy enum** — `lifecyclePolicy` changed from `String` to `LifecyclePolicy` enum with `@JsonValue`/`@JsonCreator` for kebab-case JSON. Typos now fail at deserialization instead of silently skipping cleanup. +- **M2: synchronizedList streaming** — `findMemberIncludingDynamic()` now wraps `findMember(dynamicMembers)` in `synchronized(dynamicMembers)` block. +- **M3: Cycle detection** — `SharedTaskList.detectCycles()` now called after task list dependency resolution. Circular deps throw `GroupDiscussionException` fail-fast. +- **M5: unretainAgent()** — New `@Tool` method on `TeardownAgentTool` to remove retention flags. +- **M6: Agent removal after teardown** — `createdAgentIds.remove(agentId)` after successful undeploy, so counter accurately reflects active agents. +- **M10: Case-insensitive guardrails** — Provider/model allow-list checks now use `equalsIgnoreCase()`. + +### Test Updates +- `DynamicAgentToolsTest`: +8 ConverseWithAgentTool tests, updated quota test, updated enum assertions +- `GroupConversationTest`: Updated enum count assertions (TranscriptEntryType 11→14, GroupConversationState 5→6) +- **9,486 tests pass, 0 failures** + +--- + +## ✨ Dynamic Agent System — Create, Recruit, Delegate (2026-06-25) + + +**Repo:** EDDI (`feat/group-task-orchestration`) +**What changed:** LLM agents in TASK_FORCE group conversations can now dynamically create, recruit, converse with, and teardown other agents at runtime. This enables agentic patterns where a moderator or specialist agent can spin up sub-agents on-the-fly to accomplish tasks. + +### Config Model +- **`DynamicAgentConfig`** — new inner class on `AgentGroupConfiguration` with config switches for creation, recruitment, delegation, guardrails (provider/model whitelists, per-discussion caps), and lifecycle policy (ephemeral/keep-deployed/undeploy-only/agent-decides) +- **`GroupConversation`** — added `dynamicMembers`, `createdAgentIds`, `retainedAgentIds` fields for runtime tracking + +### 4 LLM Tools (all `@Vetoed`, per-invocation constructed) +- **`CreateSubAgentTool`** — creates + deploys agent via `AgentSetupService`, quota-gated, guardrail-validated, optional initial message +- **`ConverseWithAgentTool`** — send messages to any deployed agent, supports multi-turn via conversationId +- **`FindAgentsByCapabilityTool`** — discover agents by skill via `CapabilityRegistryService` +- **`TeardownAgentTool`** — undeploy/delete created agents + `retainAgent` for lifecycle override + +### Wiring +- `AgentOrchestrator` + `LlmTask` — 5 new CDI dependencies, whitelist-gated tool names: `create_sub_agent`, `converse_with_agent`, `find_agents_by_capability`, `teardown_agent` +- `GroupConversationService` — `findMemberIncludingDynamic()` for task assignment to dynamic members, `cleanupEphemeralAgents()` in finally block with lifecycle policy enforcement + +### Tests +- **`DynamicAgentToolsTest`** — 22 tests: CreateSubAgent (8), FindAgents (4), Teardown (5), DynamicAgentConfig (2), GroupConversation fields (6) +- All existing test files updated for new constructor signatures (11 files) + +--- + +## 🐛 Fix: Tenant Quota Enforcement in Group Conversations (2026-06-25) + +**Repo:** EDDI (`feat/group-task-orchestration`) +**What changed:** `QuotaExceededException` from `ConversationService` was being silently caught and treated as a per-agent skip/retry. Now detected at 4 levels and causes immediate abort — prevents burning N round-trips when quota is exhausted. + +- `executeAgentTurn` → `startConversation()`: immediate `GroupDiscussionException` +- `executeAgentTurn` → `say()`: unwrap from `ExecutionException`, abort (bypasses retry policy) +- Task execution loop: quota error exits the agent's `CompletableFuture` immediately +- Parallel phase: quota propagates through `CompletionException`, cancels remaining futures +- Review fix: quota errors in task loop now propagate regardless of `onAgentFailure` policy (was silently lost with SKIP policy) +- +3 regression tests (startConversation quota, say() quota, no-retry-even-with-RETRY-policy). Total: 112 tests, 0 failures. + +--- + +## 🐛 Fix: Final Review — Duplicate Task Bug, Regression Tests (2026-06-25) + +**Repo:** EDDI (`feat/group-task-orchestration`) +**What changed:** Second review pass found 3 remaining issues (1 CRITICAL, 1 MEDIUM, 1 dead code). All fixed. Added comprehensive regression tests. + +- **C1-final**: `addTask→updateTask` — pre-configured dependency resolution was APPENDING tasks with same ID instead of REPLACING, silently breaking dependency ordering +- **M1-final**: `setMemberConversationIds` defensively wraps in `ConcurrentHashMap` (MongoDB deserialization was replacing with `LinkedHashMap`) +- **Dead code**: Removed unused `snapshotTranscript` from `executeTaskExecutionPhase` +- **New**: `SharedTaskList.updateTask()` public synchronized method +- **Regression tests**: +20 tests covering `resolveTaskAssignment` (7), `tryParseVerificationJson` (6), `handleTaskFailure` (2), `setMemberConversationIds` (2), `updateTask` (3). Total: 109 tests, 0 failures. + +--- + +## 🐛 Fix: TASK_FORCE Code Review — Thread Safety, Verification Parser, Error Handling (2026-06-25) + +**Repo:** EDDI (`feat/group-task-orchestration`) +**What changed:** Three-pass code review identified 4 CRITICAL, 6 HIGH, and 4 MEDIUM issues. All fixed. + +### Critical Fixes (C1–C4) +- **Thread safety**: All `SharedTaskList` public methods now `synchronized` — prevents race conditions during parallel EXECUTE phase +- **ConcurrentHashMap**: `GroupConversation.memberConversationIds` changed from `LinkedHashMap` to `ConcurrentHashMap` +- **Dependency resolution**: Pre-configured `TaskDefinition.dependsOn` subjects now resolved to actual task IDs (was silently dropped) +- **Null guard**: `resolveTaskAssignment` null returns no longer crash `assignTask` + +### High Fixes (H1–H6) +- **Transcript snapshot**: EXECUTE phase now takes `List.copyOf(gc.getTranscript())` before launching parallel futures (consistent with `executeParallelPhase`) +- **Timeout semantics**: Changed from `timeout × agentCount` to `timeout × maxTasksPerAgent` (agents run in parallel, tasks per agent are sequential) +- **Round-robin assignment**: `resolveTaskAssignment("ALL")` now distributes evenly across non-moderator members (was always picking first) +- **Verification parser**: Dedicated JSON parser reads `passed` boolean directly (was using heuristic `contains("fail")`) +- **IllegalStateException**: Now caught alongside `GroupDiscussionException` in parallel EXECUTE lambda +- **Error events**: New `handleTaskFailure()` method emits transcript entry + SSE event for failed tasks + +### Medium Fixes (M1–M4) +- **Slack**: `TASK_FORCE` added to `EXPANDED_STYLES` set +- **Cycle detection**: Changed from `ArrayList.contains()` O(n) to `HashSet.contains()` O(1) +- **Fallback**: `singleTaskFallback` now preserves LLM output as task description (was discarding it) +- **HITL placeholders**: `BLOCKED` and `AWAITING_APPROVAL` statuses documented as Phase 9b placeholders + +### Documentation Updates (6 files) +- `architecture.md`, `group-conversations.md`, `README.md`, `AGENTS.md`, `mcp-server.md`, `slack-integration.md`, `HANDOFF.md` — all updated from "5 styles" to "6 styles" with TASK_FORCE entries + +### New Tests (+18 tests) +- `SharedTaskListTest`: +11 tests (null findById, nonexistent IDs, verified deps, multiple deps, self-ref cycles, defensive copy, concurrent stress) +- `TaskListParserTest`: +7 tests (empty array, code-fenced JSON, empty members, missing fields, round-robin, tier-3 output preservation) + +--- + +## ✨ Feature: TASK_FORCE Group Orchestration — Collaborative Task Accomplishment (2026-06-25) + +**Repo:** EDDI (`feat/group-task-orchestration`) +**What changed:** Added a new `TASK_FORCE` discussion style to group conversations. Instead of debating, agents collaborate to accomplish concrete tasks together via a PLAN → EXECUTE → VERIFY → SYNTHESIS pipeline. + +### Key Design Decisions + +1. **Config-driven**: Tasks can be pre-configured in `AgentGroupConfiguration.tasks[]` (skips PLAN phase) or dynamically generated by the LLM via `TaskListParser` (three-tier fallback: JSON → Markdown → single task). +2. **Reuses existing infrastructure**: Task execution goes through normal agent pipelines. No new REST endpoints. +3. **State embedded in GroupConversation**: `SharedTaskList` is a field on `GroupConversation`, persisted as part of the MongoDB document. +4. **HITL forward-compatible**: `AWAITING_APPROVAL` state added to both `GroupConversationState` and `TaskStatus` for Phase 9b. +5. **Parallel execution**: Tasks for different agents run in parallel; tasks for the same agent run sequentially. + +### Files Changed (4 new, 12 modified) + +- **New**: `SharedTaskList.java`, `TaskListParser.java`, `SharedTaskListTest.java` (18 tests), `TaskListParserTest.java` (12 tests) +- **Model**: `AgentGroupConfiguration.java` (TASK_FORCE style + enums), `GroupConversation.java` (taskList field + entry types) +- **Orchestration**: `GroupConversationService.java` (~400 LOC task phase logic), `DiscussionStylePresets.java` (expansion + templates) +- **API**: `GroupConversationEventSink.java`, `IGroupConversationService.java`, `RestGroupConversation.java`, `RestAgentGroupStore.java`, `McpGroupTools.java`, `SlackGroupDiscussionListener.java` +- **Tests**: `DiscussionStylePresetsTest.java` (+5 tests), `McpGroupToolsTest.java` (fixed for new param) + --- ## 🐛 Fix: Swagger UI CSP Regression — Duplicate Header Causes Inline Script Block (2026-06-23) + **Repo:** EDDI (`fix/swagger-csp-duplicate-header`) **What changed:** Swagger UI showed a blank page on Docker with `Content-Security-Policy` blocking inline scripts (`script-src-elem` violation). diff --git a/docs/group-conversations.md b/docs/group-conversations.md index 4ef22cf2f..4b3b2d115 100644 --- a/docs/group-conversations.md +++ b/docs/group-conversations.md @@ -15,6 +15,7 @@ Group Conversations enable multiple agents to discuss a question. Each agent par | `DEVIL_ADVOCATE` | Opinion → Challenge → Defense → Synthesis | Risk assessment, stress-testing | | `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 | | `CUSTOM` | Define your own phases | Any workflow | ## Quick Start (MCP) @@ -144,6 +145,9 @@ For full control, define phases directly: | `DEFENSE` | Defend position against challenges | | `ARGUE` | Present argument for a side (debate) | | `REBUTTAL` | Counter opposing arguments | +| `PLAN` | Decompose the question into sub-tasks | +| `EXECUTE` | Work on assigned sub-task | +| `VERIFY` | Review and validate another member's work | | `SYNTHESIS` | Moderator produces balanced conclusion | ### Context Scopes @@ -155,6 +159,12 @@ For full control, define phases directly: | `LAST_PHASE` | Only the previous phase's entries | | `ANONYMOUS` | Previous entries with speaker names removed | | `OWN_FEEDBACK` | Only feedback addressed to this agent | +| `TASK_ONLY` | Only this agent's assigned task from the plan | +| `TASK_WITH_DEPS` | Assigned task plus outputs from dependency tasks | + +### Pre-Configured Tasks (TASK_FORCE) + +The TASK_FORCE style uses a 4-phase pipeline where the moderator first decomposes the question into sub-tasks (PLAN), agents execute their assigned tasks in parallel (EXECUTE), peers verify each other's work (VERIFY), and the moderator synthesizes the final result (SYNTHESIS). The `TASK_ONLY` and `TASK_WITH_DEPS` context scopes ensure agents see only the information relevant to their assigned work. ## Protocol Configuration @@ -229,6 +239,7 @@ All discussion styles use the same rendering pattern in Slack: | **DEVIL_ADVOCATE** | Agent posts → Challenger threads challenges → Agent threads defense → Synthesis | | **DEBATE** | PRO agent posts → CON agent posts → Rebuttals thread under opponents → Judge synthesizes | | **DELPHI** | Round 1 agents post → Round 2 agents post (convergence) → Synthesis | +| **TASK_FORCE** | Moderator posts plan → Agents post task results → Verifiers thread under targets → Synthesis | ### Trigger Keywords diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 171575ad7..3866338c8 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -85,7 +85,7 @@ EDDI uses **Streamable HTTP** transport, served by the Quarkus MCP Server extens | Tool | Description | | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `describe_discussion_styles` | Rich descriptions of all 5 discussion styles with phase flows, member roles, and use cases | +| `describe_discussion_styles` | Rich descriptions of all 6 discussion styles with phase flows, member roles, and use cases | | `list_groups` | List all group configurations with name, style, member count | | `read_group` | Read a group configuration's full details | | `create_group` | Create a group (members, moderator, style, roles, member types). Supports nested groups via `memberTypes=GROUP` | diff --git a/docs/slack-integration.md b/docs/slack-integration.md index 9bfab7156..a3d10f820 100644 --- a/docs/slack-integration.md +++ b/docs/slack-integration.md @@ -276,6 +276,7 @@ Each style produces a distinct phase flow, but all use the same header+thread UX | **DEVIL'S ADVOCATE** | Opinion → Challenge → Defense → Synthesis | Challenger threads under the original agent's header | | **DEBATE** | Pro Arguments → Con Arguments → Rebuttals → Judge | PRO and CON agents post separate headers; rebuttals thread under opponents | | **DELPHI** | Anonymous Round 1 → Round 2 (convergence) → Synthesis | Each round's opinions post as headers; convergence visible across rounds | +| **TASK FORCE** | Plan → Execute → Verify → Synthesis | Moderator posts plan; agents post task results; verifiers thread under targets; synthesis | #### Peer Feedback Threading 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 19d965a2b..d73b9c3bc 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 @@ -6,6 +6,8 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Objects; /** * Versioned configuration for a group of agents that can participate in @@ -23,6 +25,10 @@ public class AgentGroupConfiguration { private int maxRounds = 2; private List phases; private ProtocolConfig protocol; + /** Pre-configured task list. If non-empty, skips the PLAN phase. */ + private List tasks; + /** Dynamic agent creation and recruitment configuration. */ + private DynamicAgentConfig dynamicAgents; /** * A member of the group. Members can be individual agents or nested groups. @@ -70,6 +76,8 @@ public enum DiscussionStyle { DELPHI, /** Pro team argues → Con team argues → rebuttals → judge decides. */ DEBATE, + /** Collaborative task accomplishment: plan → execute → verify → synthesis. */ + TASK_FORCE, /** User defines phases manually. */ CUSTOM } @@ -85,20 +93,34 @@ public enum DiscussionStyle { * "ROLE:<roleName>" (e.g. "ROLE:DEVIL_ADVOCATE"). */ public record DiscussionPhase(String name, PhaseType type, String participants, TurnOrder turnOrder, ContextScope contextScope, - boolean targetEachPeer, String inputTemplate, int repeats) { + boolean targetEachPeer, String inputTemplate, int repeats, boolean requiresApproval) { /** * Convenience constructor with defaults: participants=ALL, * turnOrder=SEQUENTIAL, contextScope=FULL, no peer targeting, no custom - * template, 1 repeat. + * template, 1 repeat, no approval required. */ public DiscussionPhase(String name, PhaseType type) { - this(name, type, "ALL", TurnOrder.SEQUENTIAL, ContextScope.FULL, false, null, 1); + this(name, type, "ALL", TurnOrder.SEQUENTIAL, ContextScope.FULL, false, null, 1, false); + } + + /** + * Backward-compatible constructor without requiresApproval. + */ + public DiscussionPhase(String name, PhaseType type, String participants, TurnOrder turnOrder, ContextScope contextScope, + boolean targetEachPeer, String inputTemplate, int repeats) { + this(name, type, participants, turnOrder, contextScope, targetEachPeer, inputTemplate, repeats, false); } } public enum PhaseType { - OPINION, CRITIQUE, REVISION, CHALLENGE, DEFENSE, ARGUE, REBUTTAL, SYNTHESIS + OPINION, CRITIQUE, REVISION, CHALLENGE, DEFENSE, ARGUE, REBUTTAL, SYNTHESIS, + /** Task decomposition and assignment. */ + PLAN, + /** Task execution by assigned agents. */ + EXECUTE, + /** Verification of task results. */ + VERIFY } public enum TurnOrder { @@ -118,7 +140,11 @@ public enum ContextScope { /** Agent sees content from prior phases but not who said it. */ ANONYMOUS, /** Agent sees only entries targeted AT them (for REVISION phase). */ - OWN_FEEDBACK + OWN_FEEDBACK, + /** Agent sees only its assigned task description. */ + TASK_ONLY, + /** Agent sees its task plus results of dependency tasks. */ + TASK_WITH_DEPS } // --- Protocol (error handling / timeouts) --- @@ -227,4 +253,185 @@ public ProtocolConfig getProtocol() { public void setProtocol(ProtocolConfig protocol) { this.protocol = protocol; } + + public List getTasks() { + return tasks; + } + + public void setTasks(List tasks) { + this.tasks = tasks; + } + + public DynamicAgentConfig getDynamicAgents() { + return dynamicAgents; + } + + public void setDynamicAgents(DynamicAgentConfig dynamicAgents) { + this.dynamicAgents = dynamicAgents; + } + + // --- Task Definition --- + + /** + * A pre-configured task for config-driven task orchestration. When tasks are + * pre-defined here, the PLAN phase is skipped and these tasks are used + * directly. + * + * @param subject + * short task title + * @param description + * detailed instructions for the assigned agent + * @param assignToRole + * "ALL", "ROLE:", or specific agentId + * @param dependsOn + * subjects of tasks that must complete first + * @param priority + * 0 = highest + */ + public record TaskDefinition( + String subject, + String description, + String assignToRole, + List dependsOn, + int priority) { + + public TaskDefinition(String subject, String description) { + this(subject, description, "ALL", List.of(), 0); + } + + public TaskDefinition { + Objects.requireNonNull(subject, "Task subject must not be null"); + Objects.requireNonNull(description, "Task description must not be null"); + if (dependsOn == null) { + dependsOn = List.of(); + } + if (assignToRole == null) { + assignToRole = "ALL"; + } + } + } + + // --- Dynamic Agent Configuration --- + + /** + * Lifecycle policy for agents created during a discussion. + */ + public enum LifecyclePolicy { + EPHEMERAL, KEEP_DEPLOYED, UNDEPLOY_ONLY, AGENT_DECIDES; + + @com.fasterxml.jackson.annotation.JsonValue + public String toJson() { + return name().toLowerCase().replace('_', '-'); + } + + @com.fasterxml.jackson.annotation.JsonCreator + public static LifecyclePolicy fromJson(String value) { + if (value == null) + return EPHEMERAL; + return valueOf(value.toUpperCase().replace('-', '_')); + } + } + + /** + * Configuration for dynamic agent creation, recruitment, and delegation during + * group discussions. Controls guardrails, allowed providers/models, and + * lifecycle policy for dynamically created agents. + */ + public static class DynamicAgentConfig { + private boolean enabled; + private boolean allowCreation; + private boolean allowRecruitment; + private boolean allowDelegation = true; + + private int maxCreatedAgentsPerDiscussion = 5; + private int maxRecruitedAgentsPerDiscussion = 10; + private int maxDelegationsPerTask = 3; + + /** Allowed LLM providers for created agents. Null = inherit parent. */ + private List allowedProviders; + /** + * Allowed models per provider. Keys are provider names, values are lists of + * model names. Null = inherit parent model. + */ + private Map> allowedModels; + private boolean inheritParentModel = true; + + /** + * Lifecycle policy for agents created during the discussion. + *
    + *
  • {@code ephemeral} — auto-delete after discussion ends
  • + *
  • {@code keep-deployed} — keep deployed for future use
  • + *
  • {@code undeploy-only} — undeploy but keep config
  • + *
  • {@code agent-decides} — default ephemeral, but agent can retain
  • + *
+ */ + private LifecyclePolicy lifecyclePolicy = LifecyclePolicy.EPHEMERAL; + + public boolean isEnabled() { + return enabled; + } + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + public boolean isAllowCreation() { + return allowCreation; + } + public void setAllowCreation(boolean allowCreation) { + this.allowCreation = allowCreation; + } + public boolean isAllowRecruitment() { + return allowRecruitment; + } + public void setAllowRecruitment(boolean allowRecruitment) { + this.allowRecruitment = allowRecruitment; + } + public boolean isAllowDelegation() { + return allowDelegation; + } + public void setAllowDelegation(boolean allowDelegation) { + this.allowDelegation = allowDelegation; + } + public int getMaxCreatedAgentsPerDiscussion() { + return maxCreatedAgentsPerDiscussion; + } + public void setMaxCreatedAgentsPerDiscussion(int max) { + this.maxCreatedAgentsPerDiscussion = max; + } + public int getMaxRecruitedAgentsPerDiscussion() { + return maxRecruitedAgentsPerDiscussion; + } + public void setMaxRecruitedAgentsPerDiscussion(int max) { + this.maxRecruitedAgentsPerDiscussion = max; + } + public int getMaxDelegationsPerTask() { + return maxDelegationsPerTask; + } + public void setMaxDelegationsPerTask(int max) { + this.maxDelegationsPerTask = max; + } + public List getAllowedProviders() { + return allowedProviders; + } + public void setAllowedProviders(List allowedProviders) { + this.allowedProviders = allowedProviders; + } + public Map> getAllowedModels() { + return allowedModels; + } + public void setAllowedModels(Map> allowedModels) { + this.allowedModels = allowedModels; + } + public boolean isInheritParentModel() { + return inheritParentModel; + } + public void setInheritParentModel(boolean inheritParentModel) { + this.inheritParentModel = inheritParentModel; + } + public LifecyclePolicy getLifecyclePolicy() { + return lifecyclePolicy; + } + public void setLifecyclePolicy(LifecyclePolicy lifecyclePolicy) { + this.lifecyclePolicy = lifecyclePolicy != null ? lifecyclePolicy : LifecyclePolicy.EPHEMERAL; + } + } } 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 cee90dc5d..3f3d1b3ef 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 @@ -142,10 +142,90 @@ private DiscussionStylePresets() { As {displayName}, share your (updated) perspective. Consider the \ anonymous feedback but form your own independent judgment."""; + public static final String TEMPLATE_PLAN = """ + You are the project planner for a team of experts. + + GOAL: "{question}" + + TEAM MEMBERS: + {#for member in members} + - {member.displayName} (ID: {member.agentId}){#if member.capabilities}, skills: {member.capabilities}{/if} + {/for} + + Decompose this goal into concrete, actionable tasks. Assign each task to the most \ + suitable team member based on their expertise. Output a JSON array: + + ```json + [ + { + "subject": "Short task title", + "description": "Detailed instructions for the assigned agent", + "assignedTo": "agent-id or display-name", + "priority": 0 + } + ] + ``` + + Rules: + - Each task must be independently executable + - Assign tasks based on member expertise + - Keep tasks focused — one clear deliverable per task + - Aim for 2-6 tasks for most goals"""; + + public static final String TEMPLATE_EXECUTE = """ + You have been assigned the following task as part of a team effort. + + OVERALL GOAL: "{question}" + + YOUR TASK: {taskSubject} + {taskDescription} + + {#if dependencyResults} + PREREQUISITE RESULTS: + {#for dep in dependencyResults} + - {dep.subject}: {dep.result} + {/for} + {/if} + + Complete this task thoroughly. Provide your result as clear, actionable output."""; + + public static final String TEMPLATE_VERIFY = """ + You are reviewing the results of a collaborative task. + + ORIGINAL GOAL: "{question}" + + COMPLETED TASKS: + {#for task in completedTasks} + --- + TASK: {task.subject} + ASSIGNED TO: {task.assignedDisplayName} + DESCRIPTION: {task.description} + RESULT: {task.result} + --- + {/for} + + For each task, assess whether the result adequately addresses the task description \ + and contributes to the overall goal. Provide your assessment as JSON: + + ```json + [ + {"subject": "task title", "passed": true, "feedback": "assessment"} + ] + ```"""; + // Template lookup by phase type - private static final Map DEFAULT_TEMPLATES = Map.of(PhaseType.OPINION, TEMPLATE_OPINION_INDEPENDENT, PhaseType.CRITIQUE, - TEMPLATE_CRITIQUE, PhaseType.REVISION, TEMPLATE_REVISION, PhaseType.CHALLENGE, TEMPLATE_CHALLENGE, PhaseType.DEFENSE, TEMPLATE_DEFENSE, - PhaseType.ARGUE, TEMPLATE_ARGUE, PhaseType.REBUTTAL, TEMPLATE_REBUTTAL, PhaseType.SYNTHESIS, TEMPLATE_SYNTHESIS); + private static final Map DEFAULT_TEMPLATES = Map.ofEntries( + Map.entry(PhaseType.OPINION, TEMPLATE_OPINION_INDEPENDENT), + Map.entry(PhaseType.CRITIQUE, TEMPLATE_CRITIQUE), + Map.entry(PhaseType.REVISION, TEMPLATE_REVISION), + Map.entry(PhaseType.CHALLENGE, TEMPLATE_CHALLENGE), + Map.entry(PhaseType.DEFENSE, TEMPLATE_DEFENSE), + Map.entry(PhaseType.ARGUE, TEMPLATE_ARGUE), + Map.entry(PhaseType.REBUTTAL, TEMPLATE_REBUTTAL), + Map.entry(PhaseType.SYNTHESIS, TEMPLATE_SYNTHESIS), + Map.entry(PhaseType.PLAN, TEMPLATE_PLAN), + Map.entry(PhaseType.EXECUTE, TEMPLATE_EXECUTE), + Map.entry(PhaseType.VERIFY, TEMPLATE_VERIFY)); /** * Returns the default template for a given phase type. @@ -178,6 +258,7 @@ public static List expand(DiscussionStyle style, int maxRounds) case DEVIL_ADVOCATE -> devilAdvocate(); case DELPHI -> delphi(rounds); case DEBATE -> debate(); + case TASK_FORCE -> taskForce(); case CUSTOM -> List.of(); }; } @@ -250,4 +331,14 @@ private static List debate() { new DiscussionPhase("Rebuttal (Con)", PhaseType.REBUTTAL, "ROLE:CON", TurnOrder.SEQUENTIAL, ContextScope.FULL, false, null, 1), new DiscussionPhase("Judgment", PhaseType.SYNTHESIS, "MODERATOR", TurnOrder.SEQUENTIAL, ContextScope.FULL, false, null, 1)); } + + // --- TASK_FORCE --- + + private static List taskForce() { + return List.of( + new DiscussionPhase("Task Planning", PhaseType.PLAN, "MODERATOR", TurnOrder.SEQUENTIAL, ContextScope.FULL, false, null, 1), + new DiscussionPhase("Task Execution", PhaseType.EXECUTE, "ALL", TurnOrder.PARALLEL, ContextScope.TASK_ONLY, false, null, 1), + new DiscussionPhase("Result Verification", PhaseType.VERIFY, "MODERATOR", TurnOrder.SEQUENTIAL, ContextScope.FULL, false, null, 1), + new DiscussionPhase("Final Synthesis", PhaseType.SYNTHESIS, "MODERATOR", TurnOrder.SEQUENTIAL, ContextScope.FULL, false, null, 1)); + } } 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 1d9ec58e7..225dc5deb 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 @@ -4,11 +4,15 @@ */ package ai.labs.eddi.configs.groups.model; +import com.fasterxml.jackson.annotation.JsonIgnore; + import java.time.Instant; import java.util.ArrayList; -import java.util.LinkedHashMap; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; /** * Transcript record for a group conversation. Persisted with a single-version @@ -22,15 +26,31 @@ public class GroupConversation { private String userId; private GroupConversationState state; private String originalQuestion; - private List transcript = new ArrayList<>(); - private Map memberConversationIds = new LinkedHashMap<>(); + private List transcript = Collections.synchronizedList(new ArrayList<>()); + private Map memberConversationIds = new ConcurrentHashMap<>(); private int currentPhaseIndex; private String currentPhaseName; private String synthesizedAnswer; private int depth; + private SharedTaskList taskList; + /** Agents dynamically added during the discussion (recruited or created). */ + private List dynamicMembers = Collections.synchronizedList(new ArrayList<>()); + /** Agent IDs created during this discussion (for lifecycle cleanup). */ + private List createdAgentIds = Collections.synchronizedList(new ArrayList<>()); + /** Agent IDs explicitly retained by the creating agent (skip cleanup). */ + private Set retainedAgentIds = ConcurrentHashMap.newKeySet(); private Instant created; private Instant lastModified; + /** + * Transient reference to the group's dynamic agent configuration. Set by + * {@code GroupConversationService.executeDiscussion()} at the start of a + * discussion so that {@code executeAgentTurn()} can pass it to member agents + * via context. Never persisted to MongoDB or serialized to REST. + */ + @JsonIgnore + private transient AgentGroupConfiguration.DynamicAgentConfig dynamicAgentConfig; + /** * A single entry in the discussion transcript. Each entry records one agent's * contribution during a specific phase. @@ -95,11 +115,19 @@ public boolean hasEnvelopeData() { } public enum TranscriptEntryType { - QUESTION, OPINION, CRITIQUE, REVISION, CHALLENGE, DEFENSE, ARGUMENT, REBUTTAL, SYNTHESIS, ERROR, SKIPPED + QUESTION, OPINION, CRITIQUE, REVISION, CHALLENGE, DEFENSE, ARGUMENT, REBUTTAL, SYNTHESIS, ERROR, SKIPPED, + /** Task plan output from the PLAN phase. */ + PLAN, + /** Task execution result from the EXECUTE phase. */ + TASK_RESULT, + /** Verification assessment from the VERIFY phase. */ + VERIFICATION } public enum GroupConversationState { - CREATED, IN_PROGRESS, SYNTHESIZING, COMPLETED, FAILED + CREATED, IN_PROGRESS, SYNTHESIZING, COMPLETED, FAILED, + /** Paused for human approval — HITL foundation (Phase 9b). */ + AWAITING_APPROVAL } // --- Getters/Setters --- @@ -149,7 +177,9 @@ public List getTranscript() { } public void setTranscript(List transcript) { - this.transcript = transcript; + this.transcript = transcript != null + ? Collections.synchronizedList(new ArrayList<>(transcript)) + : Collections.synchronizedList(new ArrayList<>()); } public Map getMemberConversationIds() { @@ -157,7 +187,9 @@ public Map getMemberConversationIds() { } public void setMemberConversationIds(Map memberConversationIds) { - this.memberConversationIds = memberConversationIds; + this.memberConversationIds = memberConversationIds != null + ? new ConcurrentHashMap<>(memberConversationIds) + : new ConcurrentHashMap<>(); } public int getCurrentPhaseIndex() { @@ -207,4 +239,61 @@ public Instant getLastModified() { public void setLastModified(Instant lastModified) { this.lastModified = lastModified; } + + public SharedTaskList getTaskList() { + return taskList; + } + + public void setTaskList(SharedTaskList taskList) { + this.taskList = taskList; + } + + public List getDynamicMembers() { + return dynamicMembers; + } + + public void setDynamicMembers(List dynamicMembers) { + this.dynamicMembers = dynamicMembers != null + ? Collections.synchronizedList(new ArrayList<>(dynamicMembers)) + : Collections.synchronizedList(new ArrayList<>()); + } + + /** + * Add a dynamically recruited or created member to the conversation. + * Thread-safe. + */ + public void addDynamicMember(AgentGroupConfiguration.GroupMember member) { + dynamicMembers.add(member); + } + + public List getCreatedAgentIds() { + return createdAgentIds; + } + + public void setCreatedAgentIds(List createdAgentIds) { + this.createdAgentIds = createdAgentIds != null + ? Collections.synchronizedList(new ArrayList<>(createdAgentIds)) + : Collections.synchronizedList(new ArrayList<>()); + } + + public Set getRetainedAgentIds() { + return retainedAgentIds; + } + + public void setRetainedAgentIds(Set retainedAgentIds) { + Set newSet = ConcurrentHashMap.newKeySet(); + if (retainedAgentIds != null) { + newSet.addAll(retainedAgentIds); + } + this.retainedAgentIds = newSet; + } + + @JsonIgnore + public AgentGroupConfiguration.DynamicAgentConfig getDynamicAgentConfig() { + return dynamicAgentConfig; + } + + public void setDynamicAgentConfig(AgentGroupConfiguration.DynamicAgentConfig dynamicAgentConfig) { + this.dynamicAgentConfig = dynamicAgentConfig; + } } 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 new file mode 100644 index 000000000..10778bd3c --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/groups/model/SharedTaskList.java @@ -0,0 +1,371 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.configs.groups.model; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * Runtime task list for task-oriented group conversations. Embedded in + * {@link GroupConversation} — not a separate collection. + *

+ * Tracks tasks from planning through execution to verification, with + * dependency-aware queries and validated status transitions. + * + * @author ginccc + */ +public class SharedTaskList { + + private List tasks = new ArrayList<>(); + + /** + * A single task in the shared task list. + * + * @param id + * unique task identifier (UUID) + * @param subject + * short title + * @param description + * detailed task description + * @param status + * current status + * @param assignedAgentId + * agent or group responsible + * @param assignedDisplayName + * human-readable agent name + * @param dependsOnIds + * task IDs that must complete first + * @param result + * filled on completion + * @param verificationNote + * filled during VERIFY phase + * @param verified + * true if passed verification + * @param priority + * 0 = highest + * @param createdAt + * when this task was created + * @param completedAt + * when this task was completed + */ + public record TaskItem( + String id, + String subject, + String description, + TaskStatus status, + String assignedAgentId, + String assignedDisplayName, + List dependsOnIds, + String result, + String verificationNote, + boolean verified, + int priority, + Instant createdAt, + Instant completedAt) { + + /** + * Convenience constructor for creating a new pending task. + */ + public TaskItem(String subject, String description, int priority) { + this(UUID.randomUUID().toString(), subject, description, + TaskStatus.PENDING, null, null, List.of(), + null, null, false, priority, Instant.now(), null); + } + } + + /** + * Task lifecycle states. + *

+ * {@code BLOCKED} and {@code AWAITING_APPROVAL} are placeholders for Phase 9b + * (HITL — Human-in-the-Loop). They are recognized by {@link #failTask} as + * non-terminal but no code transitions into them yet. + */ + public enum TaskStatus { + PENDING, ASSIGNED, IN_PROGRESS, COMPLETED, VERIFIED, FAILED, + /** Phase 9b placeholder — task blocked by unmet dependency or resource. */ + BLOCKED, + /** Phase 9b placeholder — task requires human approval before proceeding. */ + AWAITING_APPROVAL + } + + // --- Query methods --- + + /** + * Tasks whose dependencies are all COMPLETED or VERIFIED and that are ready for + * execution (status is PENDING or ASSIGNED). + */ + public synchronized List findExecutableTasks() { + return tasks.stream() + .filter(t -> t.status() == TaskStatus.PENDING || t.status() == TaskStatus.ASSIGNED) + .filter(t -> t.dependsOnIds().isEmpty() || allDependenciesSatisfied(t)) + .toList(); + } + + /** + * Tasks assigned to a specific agent. + */ + public synchronized List findTasksForAgent(String agentId) { + if (agentId == null) { + return List.of(); + } + return tasks.stream() + .filter(t -> agentId.equals(t.assignedAgentId())) + .toList(); + } + + /** + * Check for circular dependencies. Returns the cycle path if found, or an empty + * list if the dependency graph is acyclic. + */ + public synchronized List detectCycles() { + // Simple DFS-based cycle detection + Set visited = new HashSet<>(); + List recursionStack = new ArrayList<>(); + + for (TaskItem task : tasks) { + List cycle = dfs(task.id(), visited, recursionStack); + if (!cycle.isEmpty()) { + return cycle; + } + } + return List.of(); + } + + /** + * All tasks regardless of status. + */ + public synchronized List all() { + return List.copyOf(tasks); + } + + /** + * Returns the number of tasks. + */ + public synchronized int size() { + return tasks.size(); + } + + /** + * Whether the task list is empty. + */ + public synchronized boolean isEmpty() { + return tasks.isEmpty(); + } + + /** + * Find a task by ID, or null if not found. + */ + public synchronized TaskItem findById(String taskId) { + if (taskId == null) { + return null; + } + return tasks.stream() + .filter(t -> t.id().equals(taskId)) + .findFirst() + .orElse(null); + } + + // --- Mutation methods --- + + /** + * Add a task to the list. Returns the added task. + */ + public synchronized TaskItem addTask(TaskItem task) { + tasks.add(task); + return task; + } + + /** + * Assign a task to an agent. Transitions PENDING → ASSIGNED. + * + * @throws IllegalStateException + * if the task is not in PENDING status + */ + public synchronized TaskItem assignTask(String taskId, String agentId, String displayName) { + TaskItem existing = requireTask(taskId); + requireStatus(existing, TaskStatus.PENDING, "assign"); + TaskItem updated = new TaskItem( + existing.id(), existing.subject(), existing.description(), + TaskStatus.ASSIGNED, agentId, displayName, + existing.dependsOnIds(), existing.result(), + existing.verificationNote(), existing.verified(), + existing.priority(), existing.createdAt(), existing.completedAt()); + replaceTask(taskId, updated); + return updated; + } + + /** + * Start a task. Transitions ASSIGNED → IN_PROGRESS. + * + * @throws IllegalStateException + * if the task is not in ASSIGNED status + */ + public synchronized TaskItem startTask(String taskId) { + TaskItem existing = requireTask(taskId); + requireStatus(existing, TaskStatus.ASSIGNED, "start"); + TaskItem updated = new TaskItem( + existing.id(), existing.subject(), existing.description(), + TaskStatus.IN_PROGRESS, existing.assignedAgentId(), existing.assignedDisplayName(), + existing.dependsOnIds(), existing.result(), + existing.verificationNote(), existing.verified(), + existing.priority(), existing.createdAt(), existing.completedAt()); + replaceTask(taskId, updated); + return updated; + } + + /** + * Complete a task with a result. Transitions IN_PROGRESS → COMPLETED. + * + * @throws IllegalStateException + * if the task is not in IN_PROGRESS status + */ + public synchronized TaskItem completeTask(String taskId, String result) { + TaskItem existing = requireTask(taskId); + requireStatus(existing, TaskStatus.IN_PROGRESS, "complete"); + TaskItem updated = new TaskItem( + existing.id(), existing.subject(), existing.description(), + TaskStatus.COMPLETED, existing.assignedAgentId(), existing.assignedDisplayName(), + existing.dependsOnIds(), result, + existing.verificationNote(), existing.verified(), + existing.priority(), existing.createdAt(), Instant.now()); + replaceTask(taskId, updated); + return updated; + } + + /** + * Verify a task. Transitions COMPLETED → VERIFIED (if passed) or FAILED. + */ + public synchronized TaskItem verifyTask(String taskId, boolean passed, String note) { + TaskItem existing = requireTask(taskId); + requireStatus(existing, TaskStatus.COMPLETED, "verify"); + TaskStatus newStatus = passed ? TaskStatus.VERIFIED : TaskStatus.FAILED; + TaskItem updated = new TaskItem( + existing.id(), existing.subject(), existing.description(), + newStatus, existing.assignedAgentId(), existing.assignedDisplayName(), + existing.dependsOnIds(), existing.result(), + note, passed, + existing.priority(), existing.createdAt(), existing.completedAt()); + replaceTask(taskId, updated); + return updated; + } + + /** + * Mark a task as failed. Any non-terminal status (not VERIFIED, not FAILED) can + * transition to FAILED. + */ + public synchronized TaskItem failTask(String taskId, String reason) { + TaskItem existing = requireTask(taskId); + if (existing.status() == TaskStatus.VERIFIED || existing.status() == TaskStatus.FAILED) { + throw new IllegalStateException( + "Cannot fail task '%s' — already in terminal status: %s" + .formatted(taskId, existing.status())); + } + TaskItem updated = new TaskItem( + existing.id(), existing.subject(), existing.description(), + TaskStatus.FAILED, existing.assignedAgentId(), existing.assignedDisplayName(), + existing.dependsOnIds(), existing.result(), + reason, false, + existing.priority(), existing.createdAt(), Instant.now()); + replaceTask(taskId, updated); + return updated; + } + + // --- Internal helpers --- + + private boolean allDependenciesSatisfied(TaskItem task) { + return task.dependsOnIds().stream().allMatch(depId -> { + TaskItem dep = findById(depId); + return dep != null && (dep.status() == TaskStatus.COMPLETED || dep.status() == TaskStatus.VERIFIED); + }); + } + + private TaskItem requireTask(String taskId) { + TaskItem task = findById(taskId); + if (task == null) { + throw new IllegalArgumentException("Task not found: " + taskId); + } + return task; + } + + private void requireStatus(TaskItem task, TaskStatus expected, String operation) { + if (task.status() != expected) { + throw new IllegalStateException( + "Cannot %s task '%s' — expected status %s but was %s" + .formatted(operation, task.id(), expected, task.status())); + } + } + + /** + * Replace a task with an updated version (same ID). Used for updating task + * metadata (e.g., adding dependency IDs) without changing status. + * + * @throws IllegalArgumentException + * if no task with the given ID exists + */ + public synchronized void updateTask(TaskItem replacement) { + boolean found = false; + for (int i = 0; i < tasks.size(); i++) { + if (tasks.get(i).id().equals(replacement.id())) { + tasks.set(i, replacement); + found = true; + break; + } + } + if (!found) { + throw new IllegalArgumentException("Task not found: " + replacement.id()); + } + } + + private void replaceTask(String taskId, TaskItem replacement) { + for (int i = 0; i < tasks.size(); i++) { + if (tasks.get(i).id().equals(taskId)) { + tasks.set(i, replacement); + return; + } + } + } + + private List dfs(String taskId, Set visited, List recursionStack) { + if (recursionStack.contains(taskId)) { + // Found a cycle — return the path + List cycle = new ArrayList<>(recursionStack.subList(recursionStack.indexOf(taskId), recursionStack.size())); + cycle.add(taskId); + return cycle; + } + if (visited.contains(taskId)) { + return List.of(); + } + + visited.add(taskId); + recursionStack.add(taskId); + + TaskItem task = findById(taskId); + if (task != null) { + for (String depId : task.dependsOnIds()) { + List cycle = dfs(depId, visited, recursionStack); + if (!cycle.isEmpty()) { + return cycle; + } + } + } + + recursionStack.remove(taskId); + return List.of(); + } + + // --- Getters/Setters for serialization --- + + public synchronized List getTasks() { + return new ArrayList<>(tasks); + } + + public synchronized void setTasks(List tasks) { + this.tasks = tasks != null ? new ArrayList<>(tasks) : new ArrayList<>(); + } +} 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 6ff06e665..2e6866f1b 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 @@ -78,6 +78,7 @@ private static String describeStyle(DiscussionStyle style) { case DEVIL_ADVOCATE -> "One designated challenger argues against the group consensus"; 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 CUSTOM -> "User-defined phases for full control over the discussion flow"; }; } diff --git a/src/main/java/ai/labs/eddi/engine/api/IGroupConversationService.java b/src/main/java/ai/labs/eddi/engine/api/IGroupConversationService.java index 8bf9b2250..76d8f3a8f 100644 --- a/src/main/java/ai/labs/eddi/engine/api/IGroupConversationService.java +++ b/src/main/java/ai/labs/eddi/engine/api/IGroupConversationService.java @@ -85,6 +85,10 @@ default void onGroupComplete(GroupConversationEventSink.GroupCompleteEvent event } default void onGroupError(GroupConversationEventSink.GroupErrorEvent event) { } + default void onTaskPlanCreated(GroupConversationEventSink.TaskPlanCreatedEvent event) { + } + default void onTaskVerified(GroupConversationEventSink.TaskVerifiedEvent event) { + } } // --- Exceptions --- 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 cc3886d3c..02e36b4d4 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java +++ b/src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java @@ -5,6 +5,7 @@ package ai.labs.eddi.engine.internal; import ai.labs.eddi.configs.agents.AgentSigningService; +import ai.labs.eddi.engine.tenancy.QuotaExceededException; import ai.labs.eddi.configs.agents.IAgentStore; import ai.labs.eddi.configs.agents.crypto.AgentPublicKey; import ai.labs.eddi.configs.agents.crypto.NonceCacheService; @@ -20,20 +21,25 @@ 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.ProtocolConfig; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.TaskDefinition; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.TurnOrder; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.LifecyclePolicy; import ai.labs.eddi.configs.groups.model.DiscussionStylePresets; import ai.labs.eddi.configs.groups.model.GroupConversation; 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; +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.datastore.IResourceStore; import ai.labs.eddi.datastore.serialization.IJsonSerialization; import ai.labs.eddi.engine.api.IConversationService; import ai.labs.eddi.engine.api.IGroupConversationService; +import ai.labs.eddi.engine.memory.ConversationOutputExtractor; import ai.labs.eddi.engine.memory.model.ConversationState; import ai.labs.eddi.engine.model.Context; import ai.labs.eddi.engine.model.Deployment.Environment; -import ai.labs.eddi.modules.output.model.OutputItem; import ai.labs.eddi.engine.model.InputData; import ai.labs.eddi.engine.runtime.IAgentFactory; import ai.labs.eddi.modules.templating.ITemplatingEngine; @@ -53,9 +59,9 @@ import java.util.stream.Collectors; /** - * Phase-based orchestrator for multi-agent group conversations. Supports 5 - * discussion styles (ROUND_TABLE, PEER_REVIEW, DEVIL_ADVOCATE, DELPHI, DEBATE) - * plus fully custom phase definitions. + * Phase-based orchestrator for multi-agent group conversations. Supports 6 + * discussion styles (ROUND_TABLE, PEER_REVIEW, DEVIL_ADVOCATE, DELPHI, DEBATE, + * TASK_FORCE) plus fully custom phase definitions. *

* Agents participate through their normal pipelines via * {@link IConversationService#say}. The orchestrator constructs phase-specific @@ -228,6 +234,11 @@ private GroupConversation executeDiscussion(GroupConversation gc, AgentGroupConf ProtocolConfig protocol = resolveProtocol(config); int maxTurns = protocol.maxTurns() > 0 ? protocol.maxTurns() : 50; + // 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. + gc.setDynamicAgentConfig(config.getDynamicAgents()); + // AtomicInteger: shared across the phase loop; parallel phases increment // from virtual threads. Mutable counter avoids passing & returning counts. var turnCounter = new java.util.concurrent.atomic.AtomicInteger(0); @@ -276,7 +287,10 @@ private GroupConversation executeDiscussion(GroupConversation gc, AgentGroupConf List speakers = resolveParticipants(phase, config.getMembers(), config.getModeratorAgentId()); - if (phase.targetEachPeer()) { + // --- 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()) { executePeerTargetedPhase(gc, config, speakers, phase, protocol, question, phaseIdx, listener, turnCounter, maxTurns); } else if (phase.turnOrder() == TurnOrder.PARALLEL) { executeParallelPhase(gc, config, speakers, phase, protocol, question, phaseIdx, listener, turnCounter, maxTurns); @@ -329,6 +343,9 @@ private GroupConversation executeDiscussion(GroupConversation gc, AgentGroupConf timerGroupDiscussion.record(System.nanoTime() - startTime, TimeUnit.NANOSECONDS); // Clean up incremental verification cursor — conversation is done lastVerifiedIndex.remove(gc.getId()); + // Ephemeral agent cleanup — undeploy/delete agents created during this + // discussion + cleanupEphemeralAgents(gc, config); } } @@ -360,6 +377,50 @@ public List listGroupConversations(String groupId, int index, return conversationStore.listByGroupId(groupId, index, limit); } + // ================================================================= + // Ephemeral agent cleanup + // ================================================================= + + /** + * Clean up agents created during a discussion based on the lifecycle policy. + * Called in the {@code executeDiscussion} finally block. + */ + private void cleanupEphemeralAgents(GroupConversation gc, AgentGroupConfiguration config) { + List createdIds = gc.getCreatedAgentIds(); + if (createdIds == null || createdIds.isEmpty()) { + return; + } + + var dynamicConfig = config.getDynamicAgents(); + LifecyclePolicy policy = dynamicConfig != null ? dynamicConfig.getLifecyclePolicy() : LifecyclePolicy.EPHEMERAL; + + for (String agentId : createdIds) { + // 'agent-decides': skip retained agents + if (policy == LifecyclePolicy.AGENT_DECIDES && gc.getRetainedAgentIds().contains(agentId)) { + LOGGER.infof("Ephemeral cleanup: agent '%s' retained by creator — skipping", agentId); + continue; + } + + // 'keep-deployed': no cleanup + if (policy == LifecyclePolicy.KEEP_DEPLOYED) { + continue; + } + + try { + boolean shouldDelete = policy == LifecyclePolicy.EPHEMERAL || policy == LifecyclePolicy.AGENT_DECIDES; + agentFactory.undeployAgent(DEFAULT_ENV, agentId, null); + LOGGER.infof("Ephemeral cleanup: undeployed agent '%s'", agentId); + + if (shouldDelete) { + agentStore.deleteAllPermanently(agentId); + LOGGER.infof("Ephemeral cleanup: deleted agent '%s'", agentId); + } + } catch (Exception e) { + LOGGER.warnf("Ephemeral cleanup failed for agent '%s': %s", agentId, e.getMessage()); + } + } + } + // ================================================================= // Phase resolution // ================================================================= @@ -412,7 +473,623 @@ private List resolveParticipants(DiscussionPhase phase, List speakers, + DiscussionPhase phase, ProtocolConfig protocol, String question, int phaseIdx, + GroupDiscussionEventListener listener, java.util.concurrent.atomic.AtomicInteger turnCounter, int maxTurns) + throws GroupDiscussionException { + + switch (phase.type()) { + case PLAN -> executeTaskPlanPhase(gc, config, speakers, phase, protocol, question, phaseIdx, listener, turnCounter, maxTurns); + case EXECUTE -> executeTaskExecutionPhase(gc, config, speakers, phase, protocol, question, phaseIdx, listener, turnCounter, maxTurns); + case VERIFY -> executeTaskVerificationPhase(gc, config, speakers, phase, protocol, question, phaseIdx, listener, turnCounter, maxTurns); + default -> LOGGER.warnf("Unexpected phase type %s routed to executeTaskPhase", phase.type()); + } + } + + /** + * PLAN phase: Decompose the goal into tasks. If pre-configured tasks exist in + * the group config, uses those directly (skipping LLM planning). Otherwise, the + * moderator agent decomposes the goal via its pipeline and the output is parsed + * with three-tier fallback (JSON → Markdown → single task). + */ + private void executeTaskPlanPhase(GroupConversation gc, AgentGroupConfiguration config, List speakers, + DiscussionPhase phase, ProtocolConfig protocol, String question, int phaseIdx, + GroupDiscussionEventListener listener, java.util.concurrent.atomic.AtomicInteger turnCounter, int maxTurns) + throws GroupDiscussionException { + + if (gc.getTaskList() == null) { + gc.setTaskList(new SharedTaskList()); + } + + boolean preConfigured = config.getTasks() != null && !config.getTasks().isEmpty(); + + if (preConfigured) { + // Config-driven tasks — skip LLM planning + // First pass: create all TaskItems + List createdItems = new ArrayList<>(); + for (TaskDefinition td : config.getTasks()) { + TaskItem task = new TaskItem(td.subject(), td.description(), td.priority()); + gc.getTaskList().addTask(task); + createdItems.add(task); + } + + // Second pass: resolve dependsOn subjects to task IDs + for (int i = 0; i < config.getTasks().size(); i++) { + TaskDefinition td = config.getTasks().get(i); + TaskItem original = createdItems.get(i); + if (td.dependsOn() != null && !td.dependsOn().isEmpty()) { + List resolvedDepIds = td.dependsOn().stream() + .map(depSubject -> createdItems.stream() + .filter(ci -> ci.subject().equalsIgnoreCase(depSubject)) + .map(TaskItem::id) + .findFirst().orElse(null)) + .filter(java.util.Objects::nonNull) + .toList(); + if (!resolvedDepIds.isEmpty()) { + // Replace with dependency-aware TaskItem + TaskItem withDeps = new TaskItem( + original.id(), original.subject(), original.description(), + original.status(), original.assignedAgentId(), original.assignedDisplayName(), + resolvedDepIds, original.result(), original.verificationNote(), + original.verified(), original.priority(), original.createdAt(), original.completedAt()); + gc.getTaskList().updateTask(withDeps); // replace with dependency-aware version + } + } + } + + // Third pass: resolve assignments with round-robin for "ALL" + for (int i = 0; i < createdItems.size(); i++) { + TaskItem task = createdItems.get(i); + TaskDefinition td = config.getTasks().get(i); + String assignedAgentId = resolveTaskAssignment( + td.assignToRole(), config.getMembers(), config.getModeratorAgentId(), i); + if (assignedAgentId != null) { + GroupMember assignedMember = findMember(config.getMembers(), assignedAgentId); + String displayName = assignedMember != null ? assignedMember.displayName() : assignedAgentId; + gc.getTaskList().assignTask(task.id(), assignedAgentId, displayName); + } else { + LOGGER.warnf("Could not resolve assignment for task '%s' with role '%s'", + task.subject(), td.assignToRole()); + } + } + + gc.getTranscript().add(new TranscriptEntry( + "system", "System", + "Pre-configured task plan: " + config.getTasks().size() + " tasks", + phaseIdx, phase.name(), TranscriptEntryType.PLAN, + Instant.now(), null, null)); + + } else { + // LLM-driven planning via moderator + if (speakers.isEmpty()) { + throw new GroupDiscussionException("PLAN phase requires a moderator but no speakers resolved"); + } + + GroupMember planner = speakers.getFirst(); + turnCounter.incrementAndGet(); + + if (listener != null) { + listener.onSpeakerStart( + new GroupConversationEventSink.SpeakerStartEvent(planner.agentId(), planner.displayName(), phaseIdx, phase.name())); + } + + // Build planning input with member info + String planTemplate = DiscussionStylePresets.defaultTemplate(PhaseType.PLAN); + Map data = new LinkedHashMap<>(); + data.put("question", question); + data.put("displayName", planner.displayName()); + List> memberList = config.getMembers().stream() + .filter(m -> !m.agentId().equals(planner.agentId()) || config.getMembers().size() == 1) + .map(m -> { + Map md = new LinkedHashMap<>(); + md.put("agentId", m.agentId()); + md.put("displayName", m.displayName()); + md.put("capabilities", m.role() != null ? m.role() : ""); + return md; + }).collect(Collectors.toList()); + data.put("members", memberList); + + String planInput; + try { + planInput = templatingEngine.processTemplate(planTemplate, data, ITemplatingEngine.TemplateMode.TEXT); + } catch (ITemplatingEngine.TemplateEngineException e) { + planInput = "Decompose this goal into tasks for your team: " + question; + } + + TranscriptEntry planEntry = executeAgentTurn(planner, gc, planInput, protocol, phaseIdx, phase, null); + gc.getTranscript().add(planEntry); + + if (listener != null) { + listener.onSpeakerComplete(new GroupConversationEventSink.SpeakerCompleteEvent( + planner.agentId(), planner.displayName(), planEntry.content(), phaseIdx, phase.name())); + } + + // Parse the plan output + List parsedTasks = TaskListParser.parse(planEntry.content(), config.getMembers()); + + for (int i = 0; i < parsedTasks.size(); i++) { + TaskListParser.ParsedTask pt = parsedTasks.get(i); + TaskItem task = new TaskItem(pt.subject(), pt.description(), pt.priority()); + gc.getTaskList().addTask(task); + + // Resolve assignment — null-safe (C4 fix) + String agentId = TaskListParser.resolveAgent(pt.assignedTo(), config.getMembers()); + if (agentId == null) { + agentId = TaskListParser.roundRobinAssign(i, config.getMembers()); + LOGGER.debugf("Could not resolve assignee '%s', round-robin assigning to %s", pt.assignedTo(), agentId); + } + if (agentId != null) { + GroupMember member = findMember(config.getMembers(), agentId); + String displayName = member != null ? member.displayName() : agentId; + gc.getTaskList().assignTask(task.id(), agentId, displayName); + } else { + LOGGER.warnf("Task '%s' has no assignable agent, will be skipped during execution", pt.subject()); + } + } + } + + // Validate no circular dependencies (covers both pre-configured and LLM-planned + // paths) + List cycles = gc.getTaskList().detectCycles(); + if (!cycles.isEmpty()) { + throw new GroupDiscussionException( + "Circular task dependencies detected: " + String.join(" → ", cycles)); + } + + // Emit task plan event + if (listener != null) { + List summaries = gc.getTaskList().all().stream() + .map(t -> new GroupConversationEventSink.TaskSummary(t.id(), t.subject(), t.assignedDisplayName(), t.priority())) + .toList(); + listener.onTaskPlanCreated(new GroupConversationEventSink.TaskPlanCreatedEvent(summaries, preConfigured)); + } + } + + /** + * EXECUTE phase: Run each assigned task by sending it to the responsible + * agent's pipeline. Tasks for different agents execute in parallel; tasks for + * the same agent execute sequentially within a single CompletableFuture. + */ + private void executeTaskExecutionPhase(GroupConversation gc, AgentGroupConfiguration config, List speakers, + DiscussionPhase phase, ProtocolConfig protocol, String question, int phaseIdx, + GroupDiscussionEventListener listener, java.util.concurrent.atomic.AtomicInteger turnCounter, int maxTurns) + throws GroupDiscussionException { + + if (gc.getTaskList() == null || gc.getTaskList().isEmpty()) { + LOGGER.warn("EXECUTE phase: no tasks to execute"); + return; + } + + // Note: unlike executeParallelPhase, no transcript snapshot is needed here + // because agents receive task-specific input via buildTaskExecutionInput(), + // not transcript context. + + List errors = Collections.synchronizedList(new ArrayList<>()); + int timeout = protocol.agentTimeoutSeconds() > 0 ? protocol.agentTimeoutSeconds() : 60; + int maxWaves = 100; // safety cap to prevent infinite loops + + // Wave loop: re-query executable tasks after each wave completes. + // Tasks that become executable when their dependencies finish are picked up + // in the next wave. This handles dependsOn chains across any depth. + for (int wave = 0; wave < maxWaves; wave++) { + Map> tasksByAgent = gc.getTaskList().findExecutableTasks().stream() + .filter(t -> t.assignedAgentId() != null) + .collect(Collectors.groupingBy(TaskItem::assignedAgentId)); + + if (tasksByAgent.isEmpty()) { + if (wave == 0) { + LOGGER.warn("EXECUTE phase: no assigned tasks found"); + } + break; // no more executable tasks — all waves complete + } + + LOGGER.debugf("EXECUTE phase wave %d: %d agents, %d tasks", + wave + 1, tasksByAgent.size(), + tasksByAgent.values().stream().mapToInt(List::size).sum()); + + // Execute agents in parallel, tasks per agent sequentially + List> futures = new ArrayList<>(); + + for (Map.Entry> agentEntry : tasksByAgent.entrySet()) { + String agentId = agentEntry.getKey(); + List agentTasks = agentEntry.getValue(); + GroupMember member = findMemberIncludingDynamic(config.getMembers(), gc, agentId); + + if (member == null) { + LOGGER.warnf("Task assigned to unknown agent '%s', skipping", agentId); + continue; + } + + CompletableFuture future = CompletableFuture.runAsync(() -> { + for (TaskItem task : agentTasks) { + if (turnCounter.get() >= maxTurns) { + break; + } + try { + turnCounter.incrementAndGet(); + gc.getTaskList().startTask(task.id()); + + if (listener != null) { + listener.onSpeakerStart(new GroupConversationEventSink.SpeakerStartEvent( + member.agentId(), member.displayName(), phaseIdx, phase.name())); + } + + // Build task-specific input + String taskInput = buildTaskExecutionInput(task, question, phase, gc); + TranscriptEntry entry = executeAgentTurn(member, gc, taskInput, protocol, phaseIdx, phase, null); + + synchronized (gc.getTranscript()) { + gc.getTranscript().add(entry); + } + + gc.getTaskList().completeTask(task.id(), entry.content()); + + if (listener != null) { + listener.onSpeakerComplete(new GroupConversationEventSink.SpeakerCompleteEvent( + member.agentId(), member.displayName(), entry.content(), phaseIdx, phase.name())); + } + + } catch (GroupDiscussionException e) { + // Quota errors are non-retryable — abort all tasks immediately + if (e.getCause() instanceof QuotaExceededException) { + errors.add(e); + return; // exit the entire agent's CompletableFuture + } + handleTaskFailure(gc, task, member, e.getMessage(), phaseIdx, phase, listener, errors, e); + if (protocol.onAgentFailure() == ProtocolConfig.MemberFailurePolicy.ABORT) { + break; + } + } catch (IllegalStateException e) { + // H5 fix: catch status transition errors (e.g., double completion) + LOGGER.warnf("Task state error for '%s': %s", task.subject(), e.getMessage()); + handleTaskFailure(gc, task, member, e.getMessage(), phaseIdx, phase, listener, errors, + new GroupDiscussionException(e.getMessage(), e)); + } + } + }, executorService); + futures.add(future); + } + + // Wait for this wave — timeout based on max tasks per agent (H2 fix) + int maxTasksPerAgent = tasksByAgent.values().stream().mapToInt(List::size).max().orElse(1); + try { + CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)) + .get(timeout * (long) maxTasksPerAgent, TimeUnit.SECONDS); + } catch (TimeoutException e) { + LOGGER.warnf("Task execution timed out for group %s (wave %d)", + LogSanitizer.sanitize(gc.getGroupId()), wave + 1); + futures.forEach(f -> f.cancel(true)); + break; + } catch (ExecutionException | InterruptedException e) { + LOGGER.warnf("Task execution error for group %s: %s", + LogSanitizer.sanitize(gc.getGroupId()), LogSanitizer.sanitize(e.getMessage())); + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + break; + } + + // Quota errors always abort, regardless of onAgentFailure policy + for (GroupDiscussionException error : errors) { + if (error.getCause() instanceof QuotaExceededException) { + throw error; + } + } + + // If ABORT policy and there were errors, stop further waves + if (protocol.onAgentFailure() == ProtocolConfig.MemberFailurePolicy.ABORT && !errors.isEmpty()) { + throw errors.getFirst(); + } + + if (turnCounter.get() >= maxTurns) { + break; + } + } + + // Final error propagation after all waves (ABORT policy) + if (protocol.onAgentFailure() == ProtocolConfig.MemberFailurePolicy.ABORT && !errors.isEmpty()) { + throw errors.getFirst(); + } + } + + /** + * VERIFY phase: The moderator reviews all completed tasks and provides + * pass/fail assessments. Results are parsed and applied to the task list. + */ + private void executeTaskVerificationPhase(GroupConversation gc, AgentGroupConfiguration config, List speakers, + DiscussionPhase phase, ProtocolConfig protocol, String question, int phaseIdx, + GroupDiscussionEventListener listener, java.util.concurrent.atomic.AtomicInteger turnCounter, + int maxTurns) + throws GroupDiscussionException { + + if (gc.getTaskList() == null || gc.getTaskList().isEmpty()) { + LOGGER.warn("VERIFY phase: no tasks to verify"); + return; + } + + List completedTasks = gc.getTaskList().all().stream() + .filter(t -> t.status() == TaskStatus.COMPLETED) + .toList(); + + if (completedTasks.isEmpty()) { + LOGGER.warn("VERIFY phase: no completed tasks to verify"); + gc.getTranscript().add(new TranscriptEntry( + "system", "System", "No completed tasks to verify", + phaseIdx, phase.name(), TranscriptEntryType.VERIFICATION, + Instant.now(), null, null)); + return; + } + + if (speakers.isEmpty()) { + LOGGER.warn("VERIFY phase: no verifier available"); + return; + } + + GroupMember verifier = speakers.getFirst(); + turnCounter.incrementAndGet(); + + if (listener != null) { + listener.onSpeakerStart( + new GroupConversationEventSink.SpeakerStartEvent(verifier.agentId(), verifier.displayName(), phaseIdx, phase.name())); + } + + // Build verification input + String verifyTemplate = DiscussionStylePresets.defaultTemplate(PhaseType.VERIFY); + Map data = new LinkedHashMap<>(); + data.put("question", question); + data.put("displayName", verifier.displayName()); + List> taskData = completedTasks.stream().map(t -> { + Map td = new LinkedHashMap<>(); + td.put("subject", t.subject()); + td.put("description", t.description()); + td.put("assignedDisplayName", t.assignedDisplayName()); + td.put("result", t.result() != null ? t.result() : "(no result)"); + return td; + }).collect(Collectors.toList()); + data.put("completedTasks", taskData); + + String verifyInput; + try { + verifyInput = templatingEngine.processTemplate(verifyTemplate, data, ITemplatingEngine.TemplateMode.TEXT); + } catch (ITemplatingEngine.TemplateEngineException e) { + verifyInput = "Review the task results and provide pass/fail for each task."; + } + + TranscriptEntry verifyEntry = executeAgentTurn(verifier, gc, verifyInput, protocol, phaseIdx, phase, null); + gc.getTranscript().add(verifyEntry); + + if (listener != null) { + listener.onSpeakerComplete(new GroupConversationEventSink.SpeakerCompleteEvent( + verifier.agentId(), verifier.displayName(), verifyEntry.content(), phaseIdx, phase.name())); + } + + // Parse verification results — same three-tier fallback + parseAndApplyVerification(gc, completedTasks, verifyEntry.content(), listener); + } + + /** + * Builds the input message for a task execution phase, respecting the + * configured context scope. + */ + private String buildTaskExecutionInput(TaskItem task, String question, DiscussionPhase phase, GroupConversation gc) { + String template = DiscussionStylePresets.defaultTemplate(PhaseType.EXECUTE); + Map data = new LinkedHashMap<>(); + data.put("question", question); + data.put("taskSubject", task.subject()); + data.put("taskDescription", task.description()); + + // Add dependency results if scope is TASK_WITH_DEPS + if (phase.contextScope() == ContextScope.TASK_WITH_DEPS && gc.getTaskList() != null) { + List> depResults = task.dependsOnIds().stream() + .map(depId -> gc.getTaskList().findById(depId)) + .filter(dep -> dep != null && dep.result() != null) + .map(dep -> { + Map dr = new LinkedHashMap<>(); + dr.put("subject", dep.subject()); + dr.put("result", dep.result()); + return dr; + }).collect(Collectors.toList()); + if (!depResults.isEmpty()) { + data.put("dependencyResults", depResults); + } + } + + try { + return templatingEngine.processTemplate(template, data, ITemplatingEngine.TemplateMode.TEXT); + } catch (ITemplatingEngine.TemplateEngineException e) { + LOGGER.warnf("Template processing failed for task execution, using plain text: %s", e.getMessage()); + return "Task: " + task.subject() + "\n" + task.description(); + } + } + + /** + * Parses verification output and applies pass/fail to the task list. Falls back + * to marking all tasks as passed if parsing fails (safe default). + */ + private void parseAndApplyVerification(GroupConversation gc, List completedTasks, + String verifyContent, GroupDiscussionEventListener listener) { + // H4 fix: dedicated verification parser that reads 'passed' boolean from JSON + try { + if (verifyContent != null && verifyContent.contains("[")) { + if (tryParseVerificationJson(gc, completedTasks, verifyContent, listener)) { + return; + } + } + } catch (Exception e) { + LOGGER.debugf("Failed to parse verification output, marking all as passed: %s", e.getMessage()); + } + + // Fallback: mark all completed tasks as verified (safe default) + for (TaskItem task : completedTasks) { + if (task.status() == TaskStatus.COMPLETED) { + gc.getTaskList().verifyTask(task.id(), true, "Auto-verified (verification parse failed)"); + if (listener != null) { + listener.onTaskVerified(new GroupConversationEventSink.TaskVerifiedEvent( + task.id(), task.subject(), true, "Auto-verified")); + } + } + } + } + + /** + * Attempts to parse verification results from JSON. The expected schema is: + * {@code [{"subject": "...", "passed": true, "feedback": "..."}]} + * + * @return true if parsing succeeded and at least one task was verified + */ + @SuppressWarnings("unchecked") + private boolean tryParseVerificationJson(GroupConversation gc, List completedTasks, + String content, GroupDiscussionEventListener listener) { + try { + // Extract JSON array from content (may be wrapped in markdown fences) + int jsonStart = content.indexOf('['); + int jsonEnd = content.lastIndexOf(']'); + if (jsonStart < 0 || jsonEnd <= jsonStart) { + return false; + } + String json = content.substring(jsonStart, jsonEnd + 1); + + var items = jsonSerialization.deserialize(json, List.class); + if (items == null || items.isEmpty()) { + return false; + } + + boolean anyVerified = false; + for (Object item : items) { + if (item instanceof Map map) { + String subject = map.containsKey("subject") ? String.valueOf(map.get("subject")) : null; + // Read 'passed' boolean directly from JSON + boolean passed = true; // default to passed + if (map.containsKey("passed")) { + Object passedVal = map.get("passed"); + passed = Boolean.TRUE.equals(passedVal) || "true".equalsIgnoreCase(String.valueOf(passedVal)); + } + String feedback = map.containsKey("feedback") ? String.valueOf(map.get("feedback")) : null; + + if (subject != null) { + for (TaskItem task : completedTasks) { + if (task.subject().equalsIgnoreCase(subject) && task.status() == TaskStatus.COMPLETED) { + gc.getTaskList().verifyTask(task.id(), passed, feedback); + if (listener != null) { + listener.onTaskVerified(new GroupConversationEventSink.TaskVerifiedEvent( + task.id(), task.subject(), passed, feedback)); + } + anyVerified = true; + break; + } + } + } + } + } + return anyVerified; + } catch (Exception e) { + LOGGER.debugf("Verification JSON parse failed: %s", e.getMessage()); + return false; + } + } + + // --- Task assignment helpers --- + + /** + * Resolves task assignment. For "ALL" role, uses round-robin across + * non-moderator members to distribute tasks evenly (H3 fix). + * + * @param taskIndex + * index of the task in the list, used for round-robin distribution + */ + private String resolveTaskAssignment(String assignToRole, List members, + String moderatorAgentId, int taskIndex) { + if (assignToRole == null || "ALL".equalsIgnoreCase(assignToRole)) { + // Round-robin across non-moderator members (H3 fix) + List eligible = members.stream() + .filter(m -> !m.agentId().equals(moderatorAgentId)) + .toList(); + if (eligible.isEmpty()) { + return members.isEmpty() ? null : members.getFirst().agentId(); + } + return eligible.get(taskIndex % eligible.size()).agentId(); + } + if (assignToRole.toUpperCase().startsWith("ROLE:")) { + String role = assignToRole.substring(5).trim(); + return members.stream() + .filter(m -> role.equalsIgnoreCase(m.role())) + .map(GroupMember::agentId) + .findFirst() + .orElse(null); + } + // Direct agentId reference + return TaskListParser.resolveAgent(assignToRole, members); + } + + /** + * Centralized error handling for task failures during EXECUTE phase (H6 fix). + * Marks the task as failed, adds an error transcript entry, emits SSE events, + * and collects the error for potential ABORT propagation. + */ + private void handleTaskFailure(GroupConversation gc, TaskItem task, GroupMember member, + String errorMessage, int phaseIdx, DiscussionPhase phase, + GroupDiscussionEventListener listener, + List errors, GroupDiscussionException ex) { + try { + gc.getTaskList().failTask(task.id(), errorMessage); + } catch (IllegalStateException ise) { + LOGGER.debugf("Could not fail task '%s' (already terminal): %s", task.id(), ise.getMessage()); + } + + // Add error transcript entry + synchronized (gc.getTranscript()) { + gc.getTranscript().add(new TranscriptEntry( + member.agentId(), member.displayName(), + "[ERROR] Task '%s' failed: %s".formatted(task.subject(), errorMessage), + phaseIdx, phase.name(), TranscriptEntryType.TASK_RESULT, + Instant.now(), null, null)); + } + + // Emit error event so SSE clients see the failure + if (listener != null) { + listener.onSpeakerComplete(new GroupConversationEventSink.SpeakerCompleteEvent( + member.agentId(), member.displayName(), + "[ERROR] " + errorMessage, phaseIdx, phase.name())); + } + + errors.add(ex); + } + + private GroupMember findMember(List members, String agentId) { + if (agentId == null) + return null; + return members.stream() + .filter(m -> agentId.equals(m.agentId())) + .findFirst() + .orElse(null); + } + + /** + * Find a member by agentId, searching both static config members and + * dynamically added members from the conversation. + */ + private GroupMember findMemberIncludingDynamic(List configMembers, GroupConversation gc, String agentId) { + GroupMember member = findMember(configMembers, agentId); + if (member == null && gc.getDynamicMembers() != null) { + List dynamicMembers = gc.getDynamicMembers(); + synchronized (dynamicMembers) { + member = findMember(dynamicMembers, agentId); + } + } + return member; + } + + // ================================================================= + // Phase execution (debate styles) // ================================================================= private void executeSequentialPhase(GroupConversation gc, AgentGroupConfiguration config, List speakers, DiscussionPhase phase, @@ -467,6 +1144,12 @@ private void executeParallelPhase(GroupConversation gc, AgentGroupConfiguration try { String input = buildPhaseInput(phase, speaker, question, snapshotTranscript, phaseIdx, null); return executeAgentTurn(speaker, gc, input, protocol, phaseIdx, phase, null); + } catch (GroupDiscussionException e) { + if (e.getCause() instanceof QuotaExceededException) { + throw new java.util.concurrent.CompletionException(e); + } + LOGGER.errorf("Parallel phase failed for %s: %s", speaker.agentId(), e.getMessage()); + return errorEntry(speaker, phaseIdx, phase, e.getMessage()); } catch (Exception e) { LOGGER.errorf("Parallel phase failed for %s: %s", speaker.agentId(), e.getMessage()); return errorEntry(speaker, phaseIdx, phase, e.getMessage()); @@ -486,6 +1169,22 @@ private void executeParallelPhase(GroupConversation gc, AgentGroupConfiguration futures.get(i).cancel(true); gc.getTranscript().add(new TranscriptEntry("unknown", "Unknown", null, phaseIdx, phase.name(), TranscriptEntryType.SKIPPED, Instant.now(), "Timeout", null)); + } catch (ExecutionException e) { + // Unwrap: CompletionException → GroupDiscussionException → + // QuotaExceededException + Throwable cause = e.getCause(); + if (cause instanceof java.util.concurrent.CompletionException ce) { + cause = ce.getCause(); + } + if (cause instanceof GroupDiscussionException gde + && gde.getCause() instanceof QuotaExceededException) { + // Cancel remaining futures and propagate + for (int j = i + 1; j < futures.size(); j++) { + futures.get(j).cancel(true); + } + throw gde; + } + gc.getTranscript().add(errorEntry(null, phaseIdx, phase, e.getMessage())); } catch (Exception e) { gc.getTranscript().add(errorEntry(null, phaseIdx, phase, e.getMessage())); } @@ -577,6 +1276,8 @@ private TranscriptEntry executeAgentTurn(GroupMember member, GroupConversation g var result = conversationService.startConversation(DEFAULT_ENV, member.agentId(), gc.getUserId(), groupContext); privateConvId = result.conversationId(); gc.getMemberConversationIds().put(member.agentId(), privateConvId); + } catch (QuotaExceededException qe) { + throw new GroupDiscussionException("Tenant quota exceeded: " + qe.getMessage(), qe); } catch (Exception e) { return handleAgentFailure(member, phaseIdx, phase, protocol, e, "Failed to start conversation", targetAgentId); } @@ -591,6 +1292,12 @@ private TranscriptEntry executeAgentTurn(GroupMember member, GroupConversation g context.put("groupConversationId", new Context(Context.ContextType.string, gc.getId())); context.put("groupDepth", new Context(Context.ContextType.string, String.valueOf(gc.getDepth()))); + // Pass group-level dynamic agent guardrails to member agents so that + // AgentOrchestrator can enforce caps, allowed providers/models, etc. + if (gc.getDynamicAgentConfig() != null) { + context.put("dynamicAgentConfig", new Context(Context.ContextType.object, gc.getDynamicAgentConfig())); + } + // Wave 6: Peer verification — if the receiving agent requires it, // verify all signed entries from prior speakers before sending context verifyPriorEntriesIfRequired(member.agentId(), gc); @@ -607,15 +1314,20 @@ private TranscriptEntry executeAgentTurn(GroupMember member, GroupConversation g CompletableFuture responseFuture = new CompletableFuture<>(); final String convId = privateConvId; - conversationService.say(DEFAULT_ENV, member.agentId(), convId, false, true, null, inputData, false, snapshot -> { + conversationService.say(DEFAULT_ENV, member.agentId(), convId, true, true, null, inputData, false, snapshot -> { String response = extractResponse(snapshot); // When the agent pipeline fails (e.g. LLM unreachable), extractResponse - // returns null because there are no output keys — only pipeline metadata. + // returns empty because there are no output keys — only pipeline metadata. // Surface the failure as explicit content so the transcript entry is not empty. - if (response == null && snapshot != null + if ((response == null || response.isEmpty()) && snapshot != null && snapshot.getConversationState() == ConversationState.ERROR) { response = "[Agent failed to produce output — conversation entered ERROR state]"; } + + // Propagate dynamic agent tracking data from the member's conversation + // memory to the GroupConversation for lifecycle cleanup. + propagateDynamicAgentTracking(snapshot, gc); + responseFuture.complete(response); }); @@ -724,6 +1436,10 @@ private TranscriptEntry executeAgentTurn(GroupMember member, GroupConversation g } catch (Exception e) { Throwable cause = e instanceof ExecutionException ? e.getCause() : e; + // Quota errors are non-retryable and affect all agents — abort immediately + if (cause instanceof QuotaExceededException) { + throw new GroupDiscussionException("Tenant quota exceeded: " + cause.getMessage(), cause); + } if (protocol.onAgentFailure() == ProtocolConfig.MemberFailurePolicy.RETRY && retries < maxRetries) { retries++; LOGGER.warnf("Agent %s failed (attempt %d/%d): %s", member.agentId(), retries, maxRetries, cause.getMessage()); @@ -825,6 +1541,18 @@ private String buildPhaseInput(DiscussionPhase phase, GroupMember speaker, Strin data.put("transcript", fullTranscript); data.put("totalPhases", phaseIdx); } + case PLAN -> { + // Provide member list for planning template + List> memberList = new ArrayList<>(); + // Note: speaker list should be the full member list for planning + data.put("members", memberList); // populated by caller via template data + } + case EXECUTE -> { + // Task-specific context populated by executeTaskPhase + } + case VERIFY -> { + // Completed tasks populated by executeTaskPhase + } default -> { // All PhaseType values handled above; default required by checkstyle } @@ -869,6 +1597,7 @@ private List> filterByScope(List transcript 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) { @@ -924,6 +1653,9 @@ private TranscriptEntryType mapPhaseToEntryType(PhaseType type) { case ARGUE -> TranscriptEntryType.ARGUMENT; case REBUTTAL -> TranscriptEntryType.REBUTTAL; case SYNTHESIS -> TranscriptEntryType.SYNTHESIS; + case PLAN -> TranscriptEntryType.PLAN; + case EXECUTE -> TranscriptEntryType.TASK_RESULT; + case VERIFY -> TranscriptEntryType.VERIFICATION; }; } @@ -992,89 +1724,59 @@ private void failConversation(GroupConversation gc) { } /** - * Extracts the human-readable text from a conversation memory snapshot. Looks - * for the {@code output} array in the last ConversationOutput map and - * concatenates all text entries (same logic as the Manager's - * {@code extractOutput()}). + * Reads dynamic agent tracking data from the member's conversation snapshot and + * propagates it to the group conversation's tracking lists. This bridges the + * gap between per-turn tool-local tracking lists and the group-level lifecycle + * tracking in {@link GroupConversation}. */ - private String extractResponse(ai.labs.eddi.engine.memory.model.SimpleConversationMemorySnapshot snapshot) { - if (snapshot == null || snapshot.getConversationOutputs() == null) { - return ""; - } - var outputs = snapshot.getConversationOutputs(); - if (outputs.isEmpty()) { - return ""; - } - var lastOutput = outputs.get(outputs.size() - 1); - if (lastOutput == null) { - return ""; - } - - var texts = new ArrayList(); - - // Format 1: Nested "output" array — may contain TextOutputItem POJOs or Maps - Object outputArray = lastOutput.get("output"); - if (outputArray instanceof List list) { - for (var item : list) { - if (item instanceof String s) { - texts.add(s); - } else if (item instanceof OutputItem oi && oi.toString() != null) { - // TextOutputItem.toString() returns the text field - texts.add(oi.toString()); - } else if (item instanceof Map map) { - Object text = map.get("text"); - if (text instanceof String s) { - texts.add(s); + static void propagateDynamicAgentTracking( + ai.labs.eddi.engine.memory.model.SimpleConversationMemorySnapshot snapshot, + GroupConversation gc) { + if (snapshot == null || snapshot.getConversationSteps() == null) { + return; + } + var steps = snapshot.getConversationSteps(); + if (steps.isEmpty()) { + return; + } + // Check the last step for tracking data + var lastStep = steps.get(steps.size() - 1); + if (lastStep == null || lastStep.getConversationStep() == null) { + return; + } + 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) { + 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); } } - } - if (!texts.isEmpty()) { - return String.join("\n", texts); - } - } - - // Format 2: Flat keys like "output:text:*" - for (var entry : lastOutput.entrySet()) { - if (entry.getKey() instanceof String key && key.startsWith("output:text:")) { - Object val = entry.getValue(); - if (val instanceof String s) { - texts.add(s); - } else if (val instanceof List list) { - for (var item : list) { - if (item instanceof String s) - texts.add(s); - else if (item instanceof Map map && map.get("text") instanceof String s) - texts.add(s); + } else if ("dynamic:retained_agent_ids" + .equals(stepData.getKey()) && stepData.getValue() instanceof java.util.Collection ids) { + for (Object id : ids) { + if (id instanceof String agentId) { + gc.getRetainedAgentIds().add(agentId); } - } else if (val instanceof Map map && map.get("text") instanceof String s) { - texts.add(s); } } } + } - if (!texts.isEmpty()) { - return String.join("\n", texts); - } - - // Check if the output contains any actual LLM-generated content. - // Output keys follow patterns like "output", "output:text:*", "reply". - // If none are present, the map only contains pipeline metadata - // (e.g. "actions", "input", "context") — return null to avoid - // serializing raw metadata as a group discussion response. - boolean hasAnyOutput = lastOutput.keySet().stream() - .anyMatch(k -> k instanceof String s && - (s.startsWith("output") || s.startsWith("reply"))); - if (!hasAnyOutput) { - return null; - } - - // Fallback: serialize the entire output map (backward compat) - try { - return jsonSerialization.serialize(lastOutput); - } catch (Exception e) { - LOGGER.warnf("Failed to serialize conversation output, falling back to toString(): %s", e.getMessage()); - return lastOutput.toString(); - } + /** + * Extracts the human-readable text from a conversation memory snapshot. + * Delegates to the shared {@link ConversationOutputExtractor} utility. + */ + private String extractResponse(ai.labs.eddi.engine.memory.model.SimpleConversationMemorySnapshot snapshot) { + String result = ConversationOutputExtractor.extractResponse(snapshot); + // Convert null to empty string for backward compatibility with GCS callers + // that check for empty-string (pipeline metadata-only snapshots still return + // null). + return result != null ? result : ""; } private String buildPlainTextFallback(DiscussionPhase phase, GroupMember speaker, String question, List transcript) { 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 4016432ad..aaa0f89d9 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/RestGroupConversation.java +++ b/src/main/java/ai/labs/eddi/engine/internal/RestGroupConversation.java @@ -120,6 +120,16 @@ public void onGroupError(GroupConversationEventSink.GroupErrorEvent event) { sendEvent(eventSink, sse, GroupConversationEventSink.EVENT_GROUP_ERROR, toJson(event)); closeQuietly(eventSink); } + + @Override + public void onTaskPlanCreated(GroupConversationEventSink.TaskPlanCreatedEvent event) { + sendEvent(eventSink, sse, GroupConversationEventSink.EVENT_TASK_PLAN_CREATED, toJson(event)); + } + + @Override + public void onTaskVerified(GroupConversationEventSink.TaskVerifiedEvent event) { + sendEvent(eventSink, sse, GroupConversationEventSink.EVENT_TASK_VERIFIED, toJson(event)); + } }; groupConversationService.startAndDiscussAsync(groupId, request.question(), userId, listener); diff --git a/src/main/java/ai/labs/eddi/engine/internal/TaskListParser.java b/src/main/java/ai/labs/eddi/engine/internal/TaskListParser.java new file mode 100644 index 000000000..b78afd358 --- /dev/null +++ b/src/main/java/ai/labs/eddi/engine/internal/TaskListParser.java @@ -0,0 +1,248 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.engine.internal; + +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.GroupMember; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jboss.logging.Logger; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parses LLM output into a structured task list with three-tier fallback. + *

    + *
  • Tier 1: JSON array (strict schema)
  • + *
  • Tier 2: Markdown numbered/bulleted list
  • + *
  • Tier 3: Single task with entire goal as description
  • + *
+ * + * @author ginccc + */ +public final class TaskListParser { + + private static final Logger LOGGER = Logger.getLogger(TaskListParser.class); + + private static final ObjectMapper MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + // Matches: 1. Title: description OR - Title: description OR * Title: + // description + private static final Pattern MARKDOWN_ITEM = Pattern.compile( + "^\\s*(?:\\d+[.)\\]]|[-*+])\\s+(.+?)(?:\\s*[:—–-]\\s+(.+))?$", + Pattern.MULTILINE); + + // Matches: (assigned to AgentName) or (assignedTo: agent-id) + private static final Pattern ASSIGNMENT = Pattern.compile( + "\\(\\s*(?:assigned\\s+to|assignedTo)\\s*[:=]?\\s*(.+?)\\s*\\)", + Pattern.CASE_INSENSITIVE); + + private TaskListParser() { + } + + /** + * A parsed task item from LLM output. + */ + public record ParsedTask(String subject, String description, String assignedTo, int priority) { + } + + /** + * Parse LLM output into task items with three-tier fallback. + * + * @param llmOutput + * the raw LLM response text + * @param members + * group members (unused by parse itself; accepted for API + * consistency — use {@link #resolveAgent} and + * {@link #roundRobinAssign} separately for assignment resolution) + * @return non-empty list of parsed tasks + */ + public static List parse(String llmOutput, List members) { + if (llmOutput == null || llmOutput.isBlank()) { + LOGGER.debug("Empty LLM output, falling back to single task"); + return singleTaskFallback(null); + } + + // Tier 1: Try JSON + List result = tryParseJson(llmOutput); + if (result != null && !result.isEmpty()) { + LOGGER.debugf("Tier 1 (JSON): parsed %d tasks", result.size()); + return result; + } + + // Tier 2: Try Markdown + result = tryParseMarkdown(llmOutput); + if (result != null && !result.isEmpty()) { + LOGGER.debugf("Tier 2 (Markdown): parsed %d tasks", result.size()); + return result; + } + + // Tier 3: Single task fallback + LOGGER.debug("Tier 3 (Fallback): treating entire output as single task"); + return singleTaskFallback(llmOutput); + } + + /** + * Resolve an assignedTo reference against the member list. Matches by agentId + * (exact) or displayName (case-insensitive). + * + * @return the agentId if found, or null if unresolvable + */ + public static String resolveAgent(String assignedTo, List members) { + if (assignedTo == null || assignedTo.isBlank() || members == null || members.isEmpty()) { + return null; + } + + // Exact ID match + for (GroupMember m : members) { + if (assignedTo.equals(m.agentId())) { + return m.agentId(); + } + } + + // Case-insensitive display name match + for (GroupMember m : members) { + if (m.displayName() != null && m.displayName().equalsIgnoreCase(assignedTo.trim())) { + return m.agentId(); + } + } + + return null; + } + + /** + * Assign tasks round-robin to members when the LLM's assignment can't be + * resolved. + */ + public static String roundRobinAssign(int taskIndex, List members) { + if (members == null || members.isEmpty()) { + return null; + } + return members.get(taskIndex % members.size()).agentId(); + } + + // --- Tier 1: JSON parsing --- + + private static List tryParseJson(String text) { + try { + // Strip markdown code fence if present + String json = text; + int jsonStart = text.indexOf('['); + int jsonEnd = text.lastIndexOf(']'); + + // Also try to find JSON wrapped in an object like {"tasks": [...]} + if (jsonStart < 0) { + int objStart = text.indexOf('{'); + int objEnd = text.lastIndexOf('}'); + if (objStart >= 0 && objEnd > objStart) { + String objJson = text.substring(objStart, objEnd + 1); + JsonNode node = MAPPER.readTree(objJson); + // Look for an array field ("tasks", "items", etc.) + for (var it = node.fields(); it.hasNext();) { + var entry = it.next(); + if (entry.getValue().isArray()) { + return parseJsonArray(entry.getValue().toString()); + } + } + } + return null; + } + + if (jsonEnd > jsonStart) { + json = text.substring(jsonStart, jsonEnd + 1); + } + + return parseJsonArray(json); + } catch (Exception e) { + LOGGER.debugf("JSON parse failed: %s", e.getMessage()); + return null; + } + } + + private static List parseJsonArray(String json) throws Exception { + List> items = MAPPER.readValue(json, new TypeReference<>() { + }); + List tasks = new ArrayList<>(); + for (Map item : items) { + String subject = stringVal(item, "subject", "title", "name"); + String desc = stringVal(item, "description", "desc", "details", "instructions"); + String assignedTo = stringVal(item, "assignedTo", "assigned_to", "assignee", "agent"); + int priority = intVal(item, "priority", 0); + + if (subject == null || subject.isBlank()) { + continue; // Skip entries without a subject + } + if (desc == null) { + desc = subject; // Use subject as description if missing + } + tasks.add(new ParsedTask(subject, desc, assignedTo, priority)); + } + return tasks.isEmpty() ? null : tasks; + } + + // --- Tier 2: Markdown parsing --- + + private static List tryParseMarkdown(String text) { + Matcher matcher = MARKDOWN_ITEM.matcher(text); + List tasks = new ArrayList<>(); + int index = 0; + + while (matcher.find()) { + String titlePart = matcher.group(1).trim(); + String descPart = matcher.group(2); + + // Check for inline assignment + String assignedTo = null; + Matcher assignMatcher = ASSIGNMENT.matcher(titlePart); + if (assignMatcher.find()) { + assignedTo = assignMatcher.group(1).trim(); + titlePart = titlePart.substring(0, assignMatcher.start()).trim(); + } + + String subject = titlePart; + String description = descPart != null ? descPart.trim() : titlePart; + + tasks.add(new ParsedTask(subject, description, assignedTo, index)); + index++; + } + + return tasks.isEmpty() ? null : tasks; + } + + // --- Tier 3: Single task fallback --- + + private static List singleTaskFallback(String llmOutput) { + String description = llmOutput != null && !llmOutput.isBlank() + ? llmOutput.substring(0, Math.min(llmOutput.length(), 2000)) + : "Complete the assigned goal"; + return List.of(new ParsedTask("Complete goal", description, null, 0)); + } + + // --- Helpers --- + + private static String stringVal(Map map, String... keys) { + for (String key : keys) { + Object val = map.get(key); + if (val != null) { + return val.toString(); + } + } + return null; + } + + private static int intVal(Map map, String key, int defaultVal) { + Object val = map.get(key); + if (val instanceof Number n) { + return n.intValue(); + } + return defaultVal; + } +} 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 df9093f2a..0d29e375a 100644 --- a/src/main/java/ai/labs/eddi/engine/lifecycle/GroupConversationEventSink.java +++ b/src/main/java/ai/labs/eddi/engine/lifecycle/GroupConversationEventSink.java @@ -32,6 +32,8 @@ private GroupConversationEventSink() { public static final String EVENT_SYNTHESIS_COMPLETE = "synthesis_complete"; public static final String EVENT_GROUP_COMPLETE = "group_complete"; public static final String EVENT_GROUP_ERROR = "group_error"; + public static final String EVENT_TASK_PLAN_CREATED = "task_plan_created"; + public static final String EVENT_TASK_VERIFIED = "task_verified"; // --- Event payloads --- @@ -68,4 +70,13 @@ public record GroupCompleteEvent(GroupConversation.GroupConversationState state, public record GroupErrorEvent(String error) { } + + public record TaskPlanCreatedEvent(List tasks, boolean preConfigured) { + } + + public record TaskVerifiedEvent(String taskId, String taskSubject, boolean passed, String feedback) { + } + + public record TaskSummary(String id, String subject, String assignedTo, int priority) { + } } 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 fadb2f04d..a91dcc203 100644 --- a/src/main/java/ai/labs/eddi/engine/mcp/McpGroupTools.java +++ b/src/main/java/ai/labs/eddi/engine/mcp/McpGroupTools.java @@ -6,6 +6,7 @@ import ai.labs.eddi.configs.groups.IRestAgentGroupStore; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration; +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; import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.ProtocolConfig; @@ -17,6 +18,7 @@ import io.quarkiverse.mcp.server.Tool; import io.quarkiverse.mcp.server.ToolArg; import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.common.annotation.Blocking; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.core.Response; @@ -60,8 +62,8 @@ public McpGroupTools(IRestAgentGroupStore groupStore, IGroupConversationService // --- Discovery --- @Tool(description = "Describe all available discussion styles for agent " + "groups. Returns the name, phase flow, and recommended use case " - + "for each style (ROUND_TABLE, PEER_REVIEW, DEVIL_ADVOCATE, " + "DELPHI, DEBATE). Call this before create_group to choose the " - + "right style.") + + "for each style (ROUND_TABLE, PEER_REVIEW, DEVIL_ADVOCATE, " + "DELPHI, DEBATE, TASK_FORCE). Call this before create_group to " + + "choose the right style.") public String describe_discussion_styles() { requireRole(identity, authEnabled, "eddi-viewer"); return """ @@ -92,6 +94,16 @@ public String describe_discussion_styles() { Use when: Evaluating trade-offs, pro/con analysis, technology comparisons. Member roles: assign members role=PRO or role=CON. Moderator acts as judge. + ### TASK_FORCE + Flow: Plan → Execute (parallel) → Verify → Synthesis + TASK_FORCE — Collaborative task accomplishment. The moderator decomposes the + goal into tasks, each agent executes their assigned tasks in parallel, a + verification phase checks results, and a synthesis phase combines everything. + Best for: concrete deliverables, divide-and-conquer goals, project-style work. + Member roles: none required. Moderator handles planning and synthesis. + Optional: pass pre-configured tasks via the `tasks` parameter to skip the + PLAN phase entirely. + ## Nested Groups (Group-of-Groups) Members can be other groups (memberTypes=GROUP). The sub-group runs its own full discussion and its synthesized answer becomes the member's response. @@ -102,6 +114,9 @@ Members can be other groups (memberTypes=GROUP). The sub-group runs its - moderatorAgentId: required for synthesis/judging phase - memberRoles: comma-separated roles matching member positions - memberTypes: comma-separated types: AGENT (default) or GROUP + - tasks: (TASK_FORCE only) JSON array of pre-configured task definitions. + Each task: {"subject":"...","description":"...","assignToRole":"ALL", + "dependsOn":[],"priority":0}. If provided, the PLAN phase is skipped. """; } @@ -154,10 +169,14 @@ public String create_group(@ToolArg(description = "Group name") String name, + "(default) or GROUP for nested groups (optional)") String memberTypes, @ToolArg(description = "Moderator agent ID (optional)") String moderatorAgentId, @ToolArg(description = "Discussion style: ROUND_TABLE, PEER_REVIEW, " - + "DEVIL_ADVOCATE, DELPHI, DEBATE (default ROUND_TABLE)") String style, + + "DEVIL_ADVOCATE, DELPHI, DEBATE, TASK_FORCE (default ROUND_TABLE)") String style, @ToolArg(description = "Max rounds (default 2)") String maxRounds, @ToolArg(description = "Maximum total agent turns across all phases (default 50). " - + "Safety cap to prevent runaway discussions.") String maxTurns) { + + "Safety cap to prevent runaway discussions.") String maxTurns, + @ToolArg(description = "JSON array of pre-configured tasks for TASK_FORCE style " + + "(optional). Each element: {\"subject\":\"...\",\"description\":\"...\"," + + "\"assignToRole\":\"ALL\",\"dependsOn\":[],\"priority\":0}. " + + "If provided, the PLAN phase is skipped.") String tasks) { requireRole(identity, authEnabled, "eddi-editor"); try { AgentGroupConfiguration config = new AgentGroupConfiguration(); @@ -200,6 +219,16 @@ public String create_group(@ToolArg(description = "Group name") String name, config.setStyle(discussionStyle); config.setMaxRounds(parseIntOrDefault(maxRounds, 2)); + // Pre-configured tasks (TASK_FORCE style — skips PLAN phase) + if (tasks != null && !tasks.isBlank()) { + try { + TaskDefinition[] taskArray = jsonSerialization.deserialize(tasks, TaskDefinition[].class); + config.setTasks(List.of(taskArray)); + } catch (Exception ex) { + return errorJson("Invalid tasks JSON: " + ex.getMessage()); + } + } + // Protocol with maxTurns safety cap int mt = parseIntOrDefault(maxTurns, 0); config.setProtocol(new ProtocolConfig(60, ProtocolConfig.MemberFailurePolicy.SKIP, 2, @@ -253,8 +282,14 @@ public String delete_group(@ToolArg(description = "Group ID") String groupId, // --- Group Conversation --- - @Tool(description = "Start a structured multi-agent discussion. All " + "configured member agents will participate using the group's " - + "discussion style. Returns the full transcript with each " + "agent's contributions organized by phase.") + @Blocking + @Tool(description = "Start a structured multi-agent discussion and wait for it to complete. " + + "All configured member agents participate using the group's discussion style. " + + "Returns the full GroupConversation including transcript, task list (for TASK_FORCE), " + + "dynamic agent tracking, and synthesized answer. " + + "WARNING: TASK_FORCE discussions with many agents/tasks can take several minutes. " + + "For long-running discussions, use start_group_discussion instead (returns immediately, " + + "poll with read_group_conversation).") public String discuss_with_group(@ToolArg(description = "Group configuration ID (from create_group " + "or list_groups)") String groupId, @ToolArg(description = "The question or topic for the group to " + "discuss") String question, @ToolArg(description = "User ID (optional, defaults to " + "'mcp-client')") String userId) { @@ -269,10 +304,15 @@ public String discuss_with_group(@ToolArg(description = "Group configuration ID } } - @Tool(description = "Read a group conversation transcript including all " + "phases, agent contributions, and synthesized answer.") + @Tool(description = "Read a group conversation including its full transcript, task list " + + "(for TASK_FORCE discussions with per-task status, assignments, and results), " + + "dynamic agent tracking (createdAgentIds, retainedAgentIds), synthesized answer, " + + "and conversation state. Use this to poll for completion after start_group_discussion, " + + "or to inspect task-level results after a TASK_FORCE discussion.") public String read_group_conversation( @ToolArg(description = "Group conversation ID (from " - + "discuss_with_group or list_group_conversations)") String groupConversationId) { + + "discuss_with_group, start_group_discussion, " + + "or list_group_conversations)") String groupConversationId) { requireRole(identity, authEnabled, "eddi-viewer"); try { GroupConversation gc = groupConversationService.readGroupConversation(groupConversationId); @@ -298,4 +338,43 @@ public String list_group_conversations(@ToolArg(description = "Group configurati return errorJson(e.getMessage()); } } + + // --- Async Discussion + Delete --- + + @Tool(description = "Start a group discussion asynchronously and return immediately " + + "with the conversation ID and IN_PROGRESS state. Use this instead of " + + "discuss_with_group for TASK_FORCE or other long-running discussions. " + + "Poll with read_group_conversation to check progress and get results " + + "when state changes to COMPLETED or FAILED.") + public String start_group_discussion( + @ToolArg(description = "Group configuration ID (from create_group or list_groups)") String groupId, + @ToolArg(description = "The question or topic for the group to discuss") String question, + @ToolArg(description = "User ID (optional, defaults to 'mcp-client')") String userId) { + requireRole(identity, authEnabled, "eddi-viewer"); + try { + String user = userId != null && !userId.isBlank() ? userId : "mcp-client"; + GroupConversation gc = groupConversationService.startAndDiscussAsync(groupId, question, user, null); + return jsonSerialization.serialize(java.util.Map.of( + "groupConversationId", gc.getId(), + "state", String.valueOf(gc.getState()), + "message", "Discussion started. Poll read_group_conversation with this ID to check progress.")); + } catch (Exception e) { + LOGGER.errorf("start_group_discussion failed: %s", e.getMessage()); + return errorJson(e.getMessage()); + } + } + + @Tool(description = "Delete a group conversation and cascade-delete all member " + + "conversations created during the discussion.") + public String delete_group_conversation( + @ToolArg(description = "Group conversation ID to delete") String groupConversationId) { + requireRole(identity, authEnabled, "eddi-editor"); + try { + groupConversationService.deleteGroupConversation(groupConversationId); + return "Deleted group conversation " + groupConversationId; + } catch (Exception e) { + LOGGER.errorf("delete_group_conversation failed: %s", e.getMessage()); + return errorJson(e.getMessage()); + } + } } diff --git a/src/main/java/ai/labs/eddi/engine/memory/ConversationOutputExtractor.java b/src/main/java/ai/labs/eddi/engine/memory/ConversationOutputExtractor.java new file mode 100644 index 000000000..6e5a3c475 --- /dev/null +++ b/src/main/java/ai/labs/eddi/engine/memory/ConversationOutputExtractor.java @@ -0,0 +1,124 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.engine.memory; + +import ai.labs.eddi.engine.memory.model.ConversationOutput; +import ai.labs.eddi.engine.memory.model.SimpleConversationMemorySnapshot; +import ai.labs.eddi.modules.output.model.OutputItem; +import org.jboss.logging.Logger; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Shared utility for extracting human-readable text from a conversation memory + * snapshot. Consolidates logic that was duplicated across + * {@code GroupConversationService}, {@code CreateSubAgentTool}, and + * {@code ConverseWithAgentTool}. + * + *

+ * Handles three output formats: + *

    + *
  1. Nested {@code output} array — items may be plain Strings, + * {@link OutputItem} POJOs, or Maps with a {@code text} key
  2. + *
  3. Flat keys like {@code output:text:*} — legacy format
  4. + *
  5. Fallback: {@code toString()} on the entire output map
  6. + *
+ * + * @since 6.0.0 + */ +public final class ConversationOutputExtractor { + + private static final Logger LOGGER = Logger.getLogger(ConversationOutputExtractor.class); + + private ConversationOutputExtractor() { + // Utility class + } + + /** + * Extracts the human-readable response text from the last conversation output + * in a snapshot. + * + * @param snapshot + * the conversation memory snapshot (may be null) + * @return the extracted text, or {@code null} if no meaningful output is found + * (e.g., pipeline metadata only) + */ + public static String extractResponse(SimpleConversationMemorySnapshot snapshot) { + if (snapshot == null || snapshot.getConversationOutputs() == null) { + return null; + } + List outputs = snapshot.getConversationOutputs(); + if (outputs.isEmpty()) { + return null; + } + ConversationOutput lastOutput = outputs.get(outputs.size() - 1); + if (lastOutput == null) { + return null; + } + + var texts = new ArrayList(); + + // Format 1: Nested "output" array — may contain TextOutputItem POJOs or Maps + Object outputArray = lastOutput.get("output"); + if (outputArray instanceof List list) { + for (var item : list) { + if (item instanceof String s) { + texts.add(s); + } else if (item instanceof OutputItem oi && oi.toString() != null) { + // TextOutputItem.toString() returns the text field + texts.add(oi.toString()); + } else if (item instanceof Map map) { + Object text = map.get("text"); + if (text instanceof String s) { + texts.add(s); + } + } + } + if (!texts.isEmpty()) { + return String.join("\n", texts); + } + } + + // Format 2: Flat keys like "output:text:*" + for (var entry : lastOutput.entrySet()) { + if (entry.getKey() instanceof String key && key.startsWith("output:text:")) { + Object val = entry.getValue(); + if (val instanceof String s) { + texts.add(s); + } else if (val instanceof List list) { + for (var item : list) { + if (item instanceof String s) + texts.add(s); + else if (item instanceof Map map && map.get("text") instanceof String s) + texts.add(s); + } + } else if (val instanceof Map map && map.get("text") instanceof String s) { + texts.add(s); + } + } + } + + if (!texts.isEmpty()) { + return String.join("\n", texts); + } + + // Check if the output contains any actual LLM-generated content. + // Output keys follow patterns like "output", "output:text:*", "reply". + // If none are present, the map only contains pipeline metadata + // (e.g. "actions", "input", "context") — return null to avoid + // serializing raw metadata as a group discussion response. + boolean hasAnyOutput = lastOutput.keySet().stream() + .anyMatch(k -> k instanceof String s && + (s.startsWith("output") || s.startsWith("reply"))); + if (!hasAnyOutput) { + return null; + } + + // Fallback: serialize the entire output map + return lastOutput.toString(); + } +} 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 2e2f8c2f7..7305b8bf1 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListener.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListener.java @@ -8,6 +8,7 @@ import ai.labs.eddi.engine.lifecycle.GroupConversationEventSink; import org.jboss.logging.Logger; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -24,9 +25,8 @@ * response lives in a thread reply. Peer feedback threads under the target * agent's message; revisions thread under the agent's own message. *

- * Compact mode code paths remain as a safety net for potential future styles - * but are currently unreachable ({@code EXPANDED_STYLES} contains all 5 - * styles). + * Compact mode code paths remain as a fallback for styles not in + * {@code EXPANDED_STYLES} (e.g. {@code CUSTOM}). * * @since 6.0.0 */ @@ -40,7 +40,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"); + "ROUND_TABLE", "PEER_REVIEW", "DEVIL_ADVOCATE", "DEBATE", "DELPHI", "TASK_FORCE"); private final SlackWebApiClient slackApi; private final String authToken; @@ -188,6 +188,50 @@ public void onGroupError(GroupConversationEventSink.GroupErrorEvent event) { } } + @Override + public void onTaskPlanCreated(GroupConversationEventSink.TaskPlanCreatedEvent event) { + List tasks = event.tasks(); + if (tasks == null || tasks.isEmpty()) { + return; + } + + var sb = new StringBuilder(); + sb.append(event.preConfigured() + ? "📝 *Task plan loaded* (pre-configured)\n" + : "📝 *Task plan created*\n"); + + for (int i = 0; i < tasks.size(); i++) { + var task = tasks.get(i); + sb.append(String.format("%d. *%s*", i + 1, task.subject())); + if (task.assignedTo() != null && !task.assignedTo().isBlank()) { + sb.append(String.format(" — assigned to _%s_", task.assignedTo())); + } + if (task.priority() > 0) { + sb.append(String.format(" [P%d]", task.priority())); + } + sb.append('\n'); + } + + String threadTs = expandedMode ? null : userThreadTs; + postSafe(channelId, threadTs, sb.toString().stripTrailing()); + } + + @Override + public void onTaskVerified(GroupConversationEventSink.TaskVerifiedEvent event) { + String emoji = event.passed() ? "✅" : "❌"; + String status = event.passed() ? "passed" : "failed"; + + var sb = new StringBuilder(); + sb.append(String.format("%s *Task %s* — %s\n", emoji, event.taskSubject(), status)); + + if (event.feedback() != null && !event.feedback().isBlank()) { + sb.append(String.format("> %s\n", event.feedback().replace("\n", "\n> "))); + } + + String threadTs = expandedMode ? null : userThreadTs; + postSafe(channelId, threadTs, sb.toString().stripTrailing()); + } + // ─── Posting strategies ─── /** 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 f44cac3ba..da4807c63 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 @@ -5,19 +5,25 @@ package ai.labs.eddi.modules.llm.impl; import ai.labs.eddi.configs.agents.model.AgentConfiguration; +import ai.labs.eddi.configs.agents.IAgentStore; import ai.labs.eddi.configs.agents.IRestAgentStore; +import ai.labs.eddi.configs.agents.CapabilityRegistryService; import ai.labs.eddi.configs.apicalls.model.ApiCall; import ai.labs.eddi.configs.apicalls.model.ApiCallsConfiguration; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.DynamicAgentConfig; import ai.labs.eddi.configs.mcpcalls.model.McpCallsConfiguration; import ai.labs.eddi.configs.workflows.IRestWorkflowStore; import ai.labs.eddi.datastore.serialization.IJsonSerialization; import ai.labs.eddi.configs.properties.IUserMemoryStore; import ai.labs.eddi.configs.properties.model.Property; +import ai.labs.eddi.engine.api.IConversationService; import ai.labs.eddi.engine.lifecycle.exceptions.LifecycleException; import ai.labs.eddi.engine.memory.IConversationMemory; import ai.labs.eddi.engine.memory.IMemoryItemConverter; import ai.labs.eddi.engine.memory.MemorySnapshotService; +import ai.labs.eddi.engine.runtime.IAgentFactory; import ai.labs.eddi.engine.runtime.client.configuration.IResourceClientLibrary; +import ai.labs.eddi.engine.setup.AgentSetupService; import com.fasterxml.jackson.core.io.JsonStringEncoder; import ai.labs.eddi.modules.apicalls.impl.IApiCallExecutor; import ai.labs.eddi.modules.llm.model.LlmConfiguration; @@ -26,6 +32,10 @@ import ai.labs.eddi.modules.llm.tools.ToolExecutionService; import ai.labs.eddi.modules.llm.tools.UserMemoryTool; import ai.labs.eddi.modules.llm.tools.ConversationRecallTool; +import ai.labs.eddi.modules.llm.tools.CreateSubAgentTool; +import ai.labs.eddi.modules.llm.tools.ConverseWithAgentTool; +import ai.labs.eddi.modules.llm.tools.FindAgentsByCapabilityTool; +import ai.labs.eddi.modules.llm.tools.TeardownAgentTool; import ai.labs.eddi.modules.llm.tools.impl.*; import dev.langchain4j.agent.tool.ToolExecutionRequest; import dev.langchain4j.agent.tool.ToolSpecification; @@ -67,6 +77,10 @@ class AgentOrchestrator { private static final String HTTPCALLS_TYPE = "eddi://ai.labs.httpcalls"; private static final String MCPCALLS_TYPE = "eddi://ai.labs.mcpcalls"; + /** Well-known data keys for dynamic agent lifecycle tracking. */ + public static final String KEY_DYNAMIC_CREATED_AGENT_IDS = "dynamic:created_agent_ids"; + public static final String KEY_DYNAMIC_RETAINED_AGENT_IDS = "dynamic:retained_agent_ids"; + // Built-in tools private final CalculatorTool calculatorTool; private final DateTimeTool dateTimeTool; @@ -93,6 +107,13 @@ class AgentOrchestrator { private final TenantQuotaService tenantQuotaService; private final MemorySnapshotService memorySnapshotService; + // Dynamic agent creation dependencies + private final AgentSetupService agentSetupService; + private final CapabilityRegistryService capabilityRegistryService; + private final IConversationService conversationService; + private final IAgentFactory agentFactory; + private final IAgentStore agentStore; + AgentOrchestrator(CalculatorTool calculatorTool, DateTimeTool dateTimeTool, WebSearchTool webSearchTool, DataFormatterTool dataFormatterTool, WebScraperTool webScraperTool, TextSummarizerTool textSummarizerTool, PdfReaderTool pdfReaderTool, WeatherTool weatherTool, FetchToolResponsePageTool fetchToolResponsePageTool, @@ -100,7 +121,9 @@ class AgentOrchestrator { IRestAgentStore restAgentStore, IRestWorkflowStore restWorkflowStore, IResourceClientLibrary resourceClientLibrary, IApiCallExecutor apiCallExecutor, IJsonSerialization jsonSerialization, IMemoryItemConverter memoryItemConverter, IUserMemoryStore userMemoryStore, ToolResponseTruncator toolResponseTruncator, TenantQuotaService tenantQuotaService, - MemorySnapshotService memorySnapshotService) { + MemorySnapshotService memorySnapshotService, + AgentSetupService agentSetupService, CapabilityRegistryService capabilityRegistryService, + IConversationService conversationService, IAgentFactory agentFactory, IAgentStore agentStore) { this.calculatorTool = calculatorTool; this.dateTimeTool = dateTimeTool; this.webSearchTool = webSearchTool; @@ -123,6 +146,11 @@ class AgentOrchestrator { this.toolResponseTruncator = toolResponseTruncator; this.tenantQuotaService = tenantQuotaService; this.memorySnapshotService = memorySnapshotService; + this.agentSetupService = agentSetupService; + this.capabilityRegistryService = capabilityRegistryService; + this.conversationService = conversationService; + this.agentFactory = agentFactory; + this.agentStore = agentStore; } /** @@ -535,6 +563,48 @@ private List collectAllBuiltInTools(LlmConfiguration.Task task, IConvers addUserMemoryToolIfEnabled(tools, memory); if (whitelist.contains("conversationRecall")) addConversationRecallToolIfEnabled(tools, task, memory); + // Dynamic agent tools (whitelist-gated, shared tracking lists) + { + List sharedCreatedIds = new java.util.concurrent.CopyOnWriteArrayList<>(); + Set sharedRetainedIds = java.util.concurrent.ConcurrentHashMap.newKeySet(); + String parentAgentId = memory.getAgentId(); + String userId = memory.getUserId(); + DynamicAgentConfig dynamicConfig = resolveDynamicAgentConfig(memory); + + boolean anyDynamicToolAdded = false; + if (whitelist.contains("create_sub_agent") && agentSetupService != null && conversationService != null) { + tools.add(new CreateSubAgentTool(agentSetupService, + conversationService, parentAgentId, userId, dynamicConfig, + sharedCreatedIds, sharedRetainedIds)); + LOGGER.debugf("[DYNAMIC] CreateSubAgentTool enabled for agent='%s'", sanitize(parentAgentId)); + anyDynamicToolAdded = true; + } + if (whitelist.contains("converse_with_agent") && conversationService != null) { + tools.add(new ConverseWithAgentTool(conversationService, userId)); + LOGGER.debugf("[DYNAMIC] ConverseWithAgentTool enabled for agent='%s'", sanitize(parentAgentId)); + } + if (whitelist.contains("find_agents_by_capability") && capabilityRegistryService != null) { + tools.add(new FindAgentsByCapabilityTool(capabilityRegistryService)); + LOGGER.debugf("[DYNAMIC] FindAgentsByCapabilityTool enabled for agent='%s'", sanitize(parentAgentId)); + } + if (whitelist.contains("teardown_agent") && agentFactory != null && agentStore != null) { + tools.add(new TeardownAgentTool(agentFactory, agentStore, sharedCreatedIds, sharedRetainedIds)); + LOGGER.debugf("[DYNAMIC] TeardownAgentTool enabled for agent='%s'", sanitize(parentAgentId)); + anyDynamicToolAdded = true; + } + + // Store tracking lists in memory step data so GroupConversationService + // can read them from the snapshot after each member turn and propagate + // to GroupConversation for lifecycle cleanup (Copilot PR review fix). + // The lists are stored by reference — after tool execution, they'll + // contain all agent IDs accumulated during this turn. + if (anyDynamicToolAdded) { + memory.getCurrentStep().storeData( + new ai.labs.eddi.engine.memory.model.Data<>(KEY_DYNAMIC_CREATED_AGENT_IDS, sharedCreatedIds)); + memory.getCurrentStep().storeData( + new ai.labs.eddi.engine.memory.model.Data<>(KEY_DYNAMIC_RETAINED_AGENT_IDS, sharedRetainedIds)); + } + } } else { // No whitelist — add all built-in tools tools.add(calculatorTool); @@ -604,6 +674,49 @@ private void addConversationRecallToolIfEnabled(List tools, LlmConfigura summaryConfig.getMaxRecallTurns()); } + /** + * Resolves the DynamicAgentConfig for the current conversation. If the agent is + * participating in a group discussion, the group's {@link DynamicAgentConfig} + * is passed via context variable {@code dynamicAgentConfig} by + * {@code GroupConversationService}. If no group config is present (standalone + * agent), a permissive default is used. + * + * @param memory + * the conversation memory to check for group context + * @return the resolved DynamicAgentConfig — group-level if available, + * permissive default otherwise + */ + private DynamicAgentConfig resolveDynamicAgentConfig(IConversationMemory memory) { + // Check if GroupConversationService injected a DynamicAgentConfig via context + var currentStep = memory.getCurrentStep(); + if (currentStep != null) { + var contextData = currentStep.getLatestData("context:dynamicAgentConfig"); + if (contextData != null) { + Object value = contextData.getResult(); + if (value instanceof ai.labs.eddi.engine.model.Context ctx && ctx.getValue() instanceof DynamicAgentConfig groupConfig) { + LOGGER.debugf("[DYNAMIC] Using group-level DynamicAgentConfig for agent='%s'", sanitize(memory.getAgentId())); + return groupConfig; + } + } + } + // Fallback: standalone agent — use permissive defaults + return createDefaultDynamicConfig(); + } + + /** + * Creates a default DynamicAgentConfig for agents without explicit group + * config. Used when individual agents have dynamic agent tools in their + * whitelist. + */ + private DynamicAgentConfig createDefaultDynamicConfig() { + var config = new DynamicAgentConfig(); + config.setEnabled(true); + config.setAllowCreation(true); + config.setAllowRecruitment(true); + config.setAllowDelegation(true); + return config; + } + // --- Httpcall auto-discovery from workflow --- /** 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 15e7849a5..75a79af1f 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 @@ -4,7 +4,9 @@ */ package ai.labs.eddi.modules.llm.impl; +import ai.labs.eddi.configs.agents.IAgentStore; import ai.labs.eddi.configs.agents.IRestAgentStore; +import ai.labs.eddi.configs.agents.CapabilityRegistryService; import ai.labs.eddi.configs.properties.IUserMemoryStore; import ai.labs.eddi.configs.apicalls.model.ApiCall; import ai.labs.eddi.configs.apicalls.model.ApiCallsConfiguration; @@ -20,8 +22,11 @@ import ai.labs.eddi.engine.lifecycle.exceptions.WorkflowConfigurationException; import ai.labs.eddi.engine.memory.*; import ai.labs.eddi.engine.memory.IConversationMemory.IWritableConversationStep; +import ai.labs.eddi.engine.api.IConversationService; +import ai.labs.eddi.engine.runtime.IAgentFactory; import ai.labs.eddi.engine.runtime.client.configuration.IResourceClientLibrary; import ai.labs.eddi.engine.runtime.service.ServiceException; +import ai.labs.eddi.engine.setup.AgentSetupService; import ai.labs.eddi.engine.tenancy.TenantQuotaService; import ai.labs.eddi.modules.apicalls.impl.PrePostUtils; import ai.labs.eddi.modules.llm.model.LlmConfiguration; @@ -123,7 +128,9 @@ public LlmTask(IResourceClientLibrary resourceClientLibrary, IDataFactory dataFa CounterweightService counterweightService, IdentityMaskingService identityMaskingService, ToolResponseTruncator toolResponseTruncator, TenantQuotaService tenantQuotaService, - MemorySnapshotService memorySnapshotService, IAttachmentStore attachmentStore) { + MemorySnapshotService memorySnapshotService, IAttachmentStore attachmentStore, + AgentSetupService agentSetupService, CapabilityRegistryService capabilityRegistryService, + IConversationService conversationService, IAgentFactory agentFactory, IAgentStore agentStore) { this.resourceClientLibrary = resourceClientLibrary; this.dataFactory = dataFactory; this.memoryItemConverter = memoryItemConverter; @@ -139,7 +146,8 @@ public LlmTask(IResourceClientLibrary resourceClientLibrary, IDataFactory dataFa textSummarizerTool, pdfReaderTool, weatherTool, fetchToolResponsePageTool, toolExecutionService, mcpToolProviderManager, a2aToolProviderManager, restAgentStore, restWorkflowStore, resourceClientLibrary, apiCallExecutor, jsonSerialization, memoryItemConverter, userMemoryStore, - toolResponseTruncator, tenantQuotaService, memorySnapshotService); + toolResponseTruncator, tenantQuotaService, memorySnapshotService, + agentSetupService, capabilityRegistryService, conversationService, agentFactory, agentStore); this.ragContextProvider = ragContextProvider; this.tokenCounterFactory = tokenCounterFactory; this.apiCallExecutor = apiCallExecutor; diff --git a/src/main/java/ai/labs/eddi/modules/llm/tools/ConverseWithAgentTool.java b/src/main/java/ai/labs/eddi/modules/llm/tools/ConverseWithAgentTool.java new file mode 100644 index 000000000..defb899b5 --- /dev/null +++ b/src/main/java/ai/labs/eddi/modules/llm/tools/ConverseWithAgentTool.java @@ -0,0 +1,122 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.modules.llm.tools; + +import ai.labs.eddi.engine.api.IConversationService; +import ai.labs.eddi.engine.api.IConversationService.ConversationResult; +import ai.labs.eddi.engine.model.Deployment.Environment; +import ai.labs.eddi.engine.model.InputData; +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; +import jakarta.enterprise.inject.Vetoed; +import org.jboss.logging.Logger; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * LLM tool for conversing with another deployed EDDI agent. Constructed + * per-invocation by {@code AgentOrchestrator} with the conversation service and + * user identity. + * + *

+ * Supports both single-turn (fire-and-forget) and multi-turn conversations. + * When a {@code conversationId} is provided, the tool continues an existing + * conversation; otherwise it starts a new one. + * + * @since 6.0.0 + */ +@Vetoed // Instantiated per-invocation by AgentOrchestrator — must NOT be a CDI bean +public class ConverseWithAgentTool { + + private static final Logger LOGGER = Logger.getLogger(ConverseWithAgentTool.class); + private static final Environment DEFAULT_ENV = Environment.production; + + private final IConversationService conversationService; + private final String userId; + + public ConverseWithAgentTool(IConversationService conversationService, String userId) { + this.conversationService = conversationService; + this.userId = userId; + } + + @Tool("Send a message to another deployed EDDI agent and receive its response. " + + "Use this for inter-agent delegation or consultation. " + + "Provide an existing conversationId for multi-turn conversations, " + + "or omit it to start a new conversation.") + public String converseWithAgent( + @P("The ID of the target agent to converse with") String agentId, + @P("The message to send to the agent") String message, + @P("Optional conversation ID for continuing a multi-turn conversation") String conversationId) { + + try { + // --- Validate parameters --- + if (agentId == null || agentId.isBlank()) { + return "⚠️ Agent ID is required."; + } + if (message == null || message.isBlank()) { + return "⚠️ Message is required."; + } + + // --- Start new conversation if no conversationId provided --- + if (conversationId == null || conversationId.isBlank()) { + try { + ConversationResult convResult = conversationService.startConversation( + DEFAULT_ENV, agentId, userId, Collections.emptyMap()); + conversationId = convResult.conversationId(); + LOGGER.debugf("[CONVERSE] Started new conversation '%s' with agent '%s'", + conversationId, agentId); + } catch (Exception e) { + LOGGER.errorf("[CONVERSE] Failed to start conversation with agent '%s': %s", + agentId, e.getMessage()); + return "❌ Failed to start conversation with agent '%s': %s" + .formatted(agentId, e.getMessage()); + } + } + + // --- Send message and wait for response --- + InputData inputData = new InputData(); + inputData.setInput(message); + + CompletableFuture responseFuture = new CompletableFuture<>(); + final String convId = conversationId; + + conversationService.say(DEFAULT_ENV, agentId, convId, + false, true, null, inputData, false, snapshot -> { + String response = extractResponse(snapshot); + if (response == null && snapshot != null + && snapshot.getConversationState() == ai.labs.eddi.engine.memory.model.ConversationState.ERROR) { + response = "[Agent failed to produce output — conversation entered ERROR state]"; + } + responseFuture.complete(response); + }); + + String response = responseFuture.get(60, TimeUnit.SECONDS); + + LOGGER.debugf("[CONVERSE] Agent '%s' responded in conversation '%s'", agentId, convId); + + return "✅ Agent response (conversationId: %s):\n%s".formatted(convId, + response != null && !response.isEmpty() ? response : "[no response]"); + + } catch (java.util.concurrent.TimeoutException e) { + LOGGER.warnf("[CONVERSE] Timeout waiting for agent '%s' response", agentId); + return "⚠️ Timeout waiting for agent '%s' to respond (60s limit).".formatted(agentId); + } catch (Exception e) { + LOGGER.errorf("[CONVERSE] Error conversing with agent '%s': %s", agentId, e.getMessage()); + return "❌ Error conversing with agent '%s': %s".formatted(agentId, e.getMessage()); + } + } + + /** + * Extracts the human-readable text from a conversation memory snapshot. + * Delegates to shared utility. + */ + private String extractResponse(ai.labs.eddi.engine.memory.model.SimpleConversationMemorySnapshot snapshot) { + return ai.labs.eddi.engine.memory.ConversationOutputExtractor.extractResponse(snapshot); + } +} diff --git a/src/main/java/ai/labs/eddi/modules/llm/tools/CreateSubAgentTool.java b/src/main/java/ai/labs/eddi/modules/llm/tools/CreateSubAgentTool.java new file mode 100644 index 000000000..ae8c6d502 --- /dev/null +++ b/src/main/java/ai/labs/eddi/modules/llm/tools/CreateSubAgentTool.java @@ -0,0 +1,240 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.modules.llm.tools; + +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.DynamicAgentConfig; +import ai.labs.eddi.engine.api.IConversationService; +import ai.labs.eddi.engine.api.IConversationService.ConversationResult; +import ai.labs.eddi.engine.model.Deployment.Environment; +import ai.labs.eddi.engine.model.InputData; +import ai.labs.eddi.engine.setup.AgentSetupService; +import ai.labs.eddi.engine.setup.AgentSetupService.AgentSetupException; +import ai.labs.eddi.engine.setup.SetupAgentRequest; +import ai.labs.eddi.engine.setup.SetupResult; +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; +import jakarta.enterprise.inject.Vetoed; +import org.jboss.logging.Logger; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * LLM tool for dynamically creating sub-agents during group conversations. + * Constructed per-invocation by {@code AgentOrchestrator} with the parent + * agent's context and the group's {@link DynamicAgentConfig} guardrails. + * + *

+ * The LLM can call this tool to spin up a new specialist agent on the fly, + * optionally sending it an initial message to bootstrap its context. + * + * @since 6.0.0 + */ +@Vetoed // Instantiated per-invocation by AgentOrchestrator — must NOT be a CDI bean +public class CreateSubAgentTool { + + private static final Logger LOGGER = Logger.getLogger(CreateSubAgentTool.class); + private static final Environment DEFAULT_ENV = Environment.production; + + private final AgentSetupService agentSetupService; + private final IConversationService conversationService; + private final String parentAgentId; + private final String userId; + private final DynamicAgentConfig config; + private final List createdAgentIds; + private final Set retainedAgentIds; + + public CreateSubAgentTool(AgentSetupService agentSetupService, + IConversationService conversationService, + String parentAgentId, + String userId, + DynamicAgentConfig config, + List createdAgentIds, + Set retainedAgentIds) { + this.agentSetupService = agentSetupService; + this.conversationService = conversationService; + this.parentAgentId = parentAgentId; + this.userId = userId; + this.config = config != null ? config : new DynamicAgentConfig(); + this.createdAgentIds = createdAgentIds != null ? createdAgentIds : new java.util.concurrent.CopyOnWriteArrayList<>(); + this.retainedAgentIds = retainedAgentIds != null ? retainedAgentIds : java.util.concurrent.ConcurrentHashMap.newKeySet(); + } + + @Tool("Create a new sub-agent dynamically. The agent is set up, deployed, and optionally sent an initial message. " + + "Use this when the current discussion requires a specialist that doesn't exist yet. " + + "The created agent's name will be auto-prefixed with the parent agent's ID.") + public String createSubAgent( + @P("Name for the new agent (will be prefixed with parent agent ID)") String name, + @P("System prompt defining the agent's behavior and expertise") String systemPrompt, + @P("LLM provider (e.g. 'openai', 'anthropic'). Optional — inherits parent if omitted") String provider, + @P("Model name (e.g. 'gpt-4o'). Optional — inherits parent if omitted") String model, + @P("Optional initial message to send to the agent after creation") String initialMessage, + @P("If true, the agent will be retained after the discussion ends. Default: false") Boolean retain) { + + try { + // --- Guardrail: creation allowed --- + if (!config.isEnabled() || !config.isAllowCreation()) { + return "⚠️ Dynamic agent creation is not enabled for this group."; + } + + // --- Guardrail: max created agents --- + if (createdAgentIds.size() >= config.getMaxCreatedAgentsPerDiscussion()) { + return "⚠️ Maximum created agents (%d) reached for this discussion." + .formatted(config.getMaxCreatedAgentsPerDiscussion()); + } + + // --- Guardrail: required parameters --- + if (name == null || name.isBlank()) { + return "⚠️ Agent name is required."; + } + if (systemPrompt == null || systemPrompt.isBlank()) { + return "⚠️ System prompt is required."; + } + + // --- Guardrail: allowed providers --- + if (provider != null && !provider.isBlank() + && config.getAllowedProviders() != null + && !config.getAllowedProviders().isEmpty()) { + boolean providerAllowed = config.getAllowedProviders().stream() + .filter(java.util.Objects::nonNull) + .anyMatch(p -> p.equalsIgnoreCase(provider)); + if (!providerAllowed) { + return "⚠️ Provider '%s' is not allowed. Allowed: %s" + .formatted(provider, config.getAllowedProviders()); + } + } + + // --- Guardrail: allowed models --- + if (model != null && !model.isBlank() + && config.getAllowedModels() != null + && !config.getAllowedModels().isEmpty()) { + if (provider != null && !provider.isBlank()) { + // Provider specified — check against that provider's model list + // (case-insensitive key match) + List allowedModels = config.getAllowedModels().entrySet().stream() + .filter(e -> e.getKey() != null && e.getKey().equalsIgnoreCase(provider)) + .map(Map.Entry::getValue) + .filter(java.util.Objects::nonNull) + .findFirst().orElse(null); + if (allowedModels != null && !allowedModels.isEmpty() + && allowedModels.stream().noneMatch(m -> m.equalsIgnoreCase(model))) { + return "⚠️ Model '%s' is not allowed for provider '%s'. Allowed: %s" + .formatted(model, provider, allowedModels); + } + } else { + // No provider specified — model must appear in at least one provider's + // allow-list + boolean modelFoundInAnyProvider = config.getAllowedModels().values().stream() + .filter(java.util.Objects::nonNull) + .flatMap(List::stream) + .filter(java.util.Objects::nonNull) + .anyMatch(m -> m.equalsIgnoreCase(model)); + if (!modelFoundInAnyProvider) { + return "⚠️ Model '%s' is not in any provider's allowed models list." + .formatted(model); + } + } + } + + // --- Build and execute setup --- + String prefixedName = parentAgentId + "/" + name.trim(); + SetupAgentRequest request = new SetupAgentRequest( + prefixedName, + systemPrompt, + provider, + model, + null, // apiKey — inherited from vault + null, // baseUrl + null, // introMessage (handled separately below) + null, // enableBuiltInTools + null, // builtInToolsWhitelist + null, // enableQuickReplies + null, // enableSentimentAnalysis + null, // mcpServerUrls + true, // deploy + null // environment + ); + + SetupResult result = agentSetupService.setupAgent(request); + String agentId = result.agentId(); + createdAgentIds.add(agentId); + if (Boolean.TRUE.equals(retain)) { + retainedAgentIds.add(agentId); + } + + LOGGER.infof("[SUB-AGENT] Created sub-agent: name='%s', agentId='%s', parent='%s'", + prefixedName, agentId, parentAgentId); + + // --- Optional: send initial message --- + String conversationId = null; + String response = null; + if (initialMessage != null && !initialMessage.isBlank()) { + try { + ConversationResult convResult = conversationService.startConversation( + DEFAULT_ENV, agentId, userId, Collections.emptyMap()); + conversationId = convResult.conversationId(); + + InputData inputData = new InputData(); + inputData.setInput(initialMessage); + + CompletableFuture responseFuture = new CompletableFuture<>(); + conversationService.say(DEFAULT_ENV, agentId, conversationId, + false, true, null, inputData, false, snapshot -> { + String text = extractResponse(snapshot); + responseFuture.complete(text); + }); + + response = responseFuture.get(60, TimeUnit.SECONDS); + } catch (Exception e) { + LOGGER.warnf("[SUB-AGENT] Initial message failed for agent '%s': %s", + agentId, e.getMessage()); + response = "[Initial message failed: " + e.getMessage() + "]"; + } + } + + // --- Build result string --- + var sb = new StringBuilder(); + sb.append("✅ Sub-agent created successfully!\n"); + sb.append("• Agent ID: ").append(agentId).append("\n"); + sb.append("• Name: ").append(prefixedName).append("\n"); + if (result.provider() != null) { + sb.append("• Provider: ").append(result.provider()).append("\n"); + } + if (result.model() != null) { + sb.append("• Model: ").append(result.model()).append("\n"); + } + if (conversationId != null) { + sb.append("• Conversation ID: ").append(conversationId).append("\n"); + } + if (response != null) { + sb.append("• Initial response: ").append(response).append("\n"); + } + if (Boolean.TRUE.equals(retain)) { + sb.append("• Lifecycle: retained (will not be auto-deleted)\n"); + } + + return sb.toString(); + + } catch (AgentSetupException e) { + LOGGER.errorf("[SUB-AGENT] Failed to create sub-agent: %s", e.getMessage()); + return "❌ Failed to create sub-agent: " + e.getMessage(); + } catch (Exception e) { + LOGGER.errorf("[SUB-AGENT] Unexpected error creating sub-agent: %s", e.getMessage()); + return "❌ Unexpected error: " + e.getMessage(); + } + } + + /** + * Extracts the human-readable text from a conversation memory snapshot. + * Delegates to shared utility. + */ + private String extractResponse(ai.labs.eddi.engine.memory.model.SimpleConversationMemorySnapshot snapshot) { + return ai.labs.eddi.engine.memory.ConversationOutputExtractor.extractResponse(snapshot); + } +} diff --git a/src/main/java/ai/labs/eddi/modules/llm/tools/FindAgentsByCapabilityTool.java b/src/main/java/ai/labs/eddi/modules/llm/tools/FindAgentsByCapabilityTool.java new file mode 100644 index 000000000..e79166bd3 --- /dev/null +++ b/src/main/java/ai/labs/eddi/modules/llm/tools/FindAgentsByCapabilityTool.java @@ -0,0 +1,98 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.modules.llm.tools; + +import ai.labs.eddi.configs.agents.CapabilityRegistryService; +import ai.labs.eddi.configs.agents.CapabilityRegistryService.CapabilityMatch; +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; +import jakarta.enterprise.inject.Vetoed; +import org.jboss.logging.Logger; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * LLM tool for discovering agents by capability using the + * {@link CapabilityRegistryService}. Constructed per-invocation by + * {@code AgentOrchestrator}. + * + *

+ * The LLM can call this tool to find agents that have registered a specific + * skill, using strategies like highest_confidence, round_robin, or all. + * + * @since 6.0.0 + */ +@Vetoed // Instantiated per-invocation by AgentOrchestrator — must NOT be a CDI bean +public class FindAgentsByCapabilityTool { + + private static final Logger LOGGER = Logger.getLogger(FindAgentsByCapabilityTool.class); + + private final CapabilityRegistryService registryService; + + public FindAgentsByCapabilityTool(CapabilityRegistryService registryService) { + this.registryService = registryService; + } + + @Tool("Find deployed agents that have a specific capability or skill. " + + "Use this to discover which agents can handle a particular task before delegating work. " + + "Strategies: 'highest_confidence' (best match), 'round_robin' (load-balanced), 'all' (every match).") + public String findAgentsByCapability( + @P("The skill or capability to search for (e.g. 'translation', 'summarization', 'code-review')") String skill, + @P("Selection strategy: 'highest_confidence', 'round_robin', or 'all'. Default: 'highest_confidence'") String strategy) { + + try { + // --- Validate parameters --- + if (skill == null || skill.isBlank()) { + return "⚠️ Skill name is required."; + } + + String effectiveStrategy = (strategy != null && !strategy.isBlank()) ? strategy : "highest_confidence"; + + LOGGER.debugf("[CAPABILITY] Searching for skill='%s' with strategy='%s'", skill, effectiveStrategy); + + List matches = registryService.findBySkill(skill, effectiveStrategy); + + if (matches == null || matches.isEmpty()) { + return "No agents found with skill '%s'.".formatted(skill); + } + + // --- Format results --- + String header = "🔍 Found %d agent(s) with skill '%s' (strategy: %s):\n" + .formatted(matches.size(), skill, effectiveStrategy); + + String body = matches.stream() + .map(this::formatMatch) + .collect(Collectors.joining("\n")); + + return header + body; + + } catch (Exception e) { + LOGGER.errorf("[CAPABILITY] Error searching for skill '%s': %s", skill, e.getMessage()); + return "❌ Error searching for agents: " + e.getMessage(); + } + } + + private String formatMatch(CapabilityMatch match) { + var sb = new StringBuilder(); + sb.append("• Agent: ").append(match.agentId()); + sb.append(" | Skill: ").append(match.skill()); + + if (match.confidence() != null && !match.confidence().isBlank()) { + sb.append(" | Confidence: ").append(match.confidence()); + } + + Map attributes = match.attributes(); + if (attributes != null && !attributes.isEmpty()) { + String attrs = attributes.entrySet().stream() + .map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining(", ")); + sb.append(" | Attributes: {").append(attrs).append("}"); + } + + return sb.toString(); + } +} diff --git a/src/main/java/ai/labs/eddi/modules/llm/tools/TeardownAgentTool.java b/src/main/java/ai/labs/eddi/modules/llm/tools/TeardownAgentTool.java new file mode 100644 index 000000000..3f8b70801 --- /dev/null +++ b/src/main/java/ai/labs/eddi/modules/llm/tools/TeardownAgentTool.java @@ -0,0 +1,153 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.modules.llm.tools; + +import ai.labs.eddi.configs.agents.IAgentStore; +import ai.labs.eddi.engine.model.Deployment.Environment; +import ai.labs.eddi.engine.runtime.IAgentFactory; +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; +import jakarta.enterprise.inject.Vetoed; +import org.jboss.logging.Logger; + +import java.util.List; +import java.util.Set; + +/** + * LLM tool for tearing down dynamically created agents. Constructed + * per-invocation by {@code AgentOrchestrator} with the factory, agent store, + * and the lists of created/retained agent IDs from the group conversation. + * + *

+ * Only agents that were created during the current discussion (tracked in + * {@code createdAgentIds}) can be torn down — preventing accidental destruction + * of pre-existing agents. + * + * @since 6.0.0 + */ +@Vetoed // Instantiated per-invocation by AgentOrchestrator — must NOT be a CDI bean +public class TeardownAgentTool { + + private static final Logger LOGGER = Logger.getLogger(TeardownAgentTool.class); + private static final Environment DEFAULT_ENV = Environment.production; + + private final IAgentFactory agentFactory; + private final IAgentStore agentStore; + private final List createdAgentIds; + private final Set retainedAgentIds; + + public TeardownAgentTool(IAgentFactory agentFactory, + IAgentStore agentStore, + List createdAgentIds, + Set retainedAgentIds) { + this.agentFactory = agentFactory; + this.agentStore = agentStore; + this.createdAgentIds = createdAgentIds != null ? createdAgentIds : new java.util.concurrent.CopyOnWriteArrayList<>(); + this.retainedAgentIds = retainedAgentIds != null ? retainedAgentIds : new java.util.concurrent.CopyOnWriteArraySet<>(); + } + + @Tool("Tear down (undeploy) a dynamically created agent. Only agents created during this discussion " + + "can be torn down. Optionally delete the agent configuration permanently.") + public String teardownAgent( + @P("The ID of the agent to tear down") String agentId, + @P("If true, permanently delete the agent config after undeploying. Default: false") Boolean delete) { + + try { + // --- Validate parameters --- + if (agentId == null || agentId.isBlank()) { + return "⚠️ Agent ID is required."; + } + + // --- Security: can only teardown agents we created --- + if (!createdAgentIds.contains(agentId)) { + return "⚠️ Cannot tear down agent '%s' — it was not created during this discussion." + .formatted(agentId); + } + + // --- Check if retained --- + if (retainedAgentIds.contains(agentId)) { + return "⚠️ Agent '%s' has been marked as retained and cannot be torn down. " + .formatted(agentId) + + "Remove the retain flag first if you want to tear it down."; + } + + // --- Undeploy --- + try { + agentFactory.undeployAgent(DEFAULT_ENV, agentId, null); + LOGGER.infof("[TEARDOWN] Undeployed agent '%s'", agentId); + } catch (Exception e) { + LOGGER.warnf("[TEARDOWN] Undeploy failed for agent '%s': %s", agentId, e.getMessage()); + return "❌ Failed to undeploy agent '%s': %s".formatted(agentId, e.getMessage()); + } + + // --- Optional: delete agent configuration --- + createdAgentIds.remove(agentId); + if (Boolean.TRUE.equals(delete)) { + try { + agentStore.deleteAllPermanently(agentId); + LOGGER.infof("[TEARDOWN] Permanently deleted agent '%s'", agentId); + return "✅ Agent '%s' has been undeployed and permanently deleted.".formatted(agentId); + } catch (Exception e) { + LOGGER.warnf("[TEARDOWN] Delete failed for agent '%s': %s", agentId, e.getMessage()); + return "⚠️ Agent '%s' was undeployed but deletion failed: %s" + .formatted(agentId, e.getMessage()); + } + } + + return "✅ Agent '%s' has been undeployed successfully.".formatted(agentId); + + } catch (Exception e) { + LOGGER.errorf("[TEARDOWN] Unexpected error tearing down agent '%s': %s", + agentId, e.getMessage()); + return "❌ Unexpected error: " + e.getMessage(); + } + } + + @Tool("Mark a dynamically created agent for retention — it will NOT be automatically deleted " + + "when the discussion ends. Use this when a created agent should be kept for future use.") + public String retainAgent( + @P("The ID of the agent to retain") String agentId) { + + try { + // --- Validate parameters --- + if (agentId == null || agentId.isBlank()) { + return "⚠️ Agent ID is required."; + } + + // --- Security: can only retain agents we created --- + if (!createdAgentIds.contains(agentId)) { + return "⚠️ Cannot retain agent '%s' — it was not created during this discussion." + .formatted(agentId); + } + + // --- Check if already retained --- + if (retainedAgentIds.contains(agentId)) { + return "ℹ️ Agent '%s' is already marked as retained.".formatted(agentId); + } + + retainedAgentIds.add(agentId); + LOGGER.infof("[TEARDOWN] Retained agent '%s'", agentId); + + return "✅ Agent '%s' has been marked as retained. It will not be auto-deleted after the discussion." + .formatted(agentId); + + } catch (Exception e) { + LOGGER.errorf("[TEARDOWN] Error retaining agent '%s': %s", agentId, e.getMessage()); + return "❌ Error retaining agent: " + e.getMessage(); + } + } + + @Tool("Remove the retention flag from a previously retained agent, allowing it to be cleaned up when the discussion ends.") + public String unretainAgent(@P("The agent ID to un-retain") String agentId) { + if (agentId == null || agentId.isBlank()) { + return "⚠️ Agent ID is required."; + } + if (!retainedAgentIds.contains(agentId)) { + return "⚠️ Agent '%s' is not currently retained.".formatted(agentId); + } + retainedAgentIds.remove(agentId); + return "✅ Retention flag removed from agent '%s'. It will be cleaned up when the discussion ends.".formatted(agentId); + } +} 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 0a31641f5..4e047a6a1 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,12 +47,13 @@ void maxRounds_setter() { @Test void style_allValues() { - assertEquals(6, DiscussionStyle.values().length); + assertEquals(7, 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("CUSTOM")); } @@ -135,7 +136,7 @@ void discussionPhase_convenienceConstructor_defaults() { @Test void phaseType_allValues() { - assertEquals(8, PhaseType.values().length); + assertEquals(11, PhaseType.values().length); assertNotNull(PhaseType.valueOf("OPINION")); assertNotNull(PhaseType.valueOf("CRITIQUE")); assertNotNull(PhaseType.valueOf("REVISION")); @@ -144,16 +145,21 @@ void phaseType_allValues() { assertNotNull(PhaseType.valueOf("ARGUE")); assertNotNull(PhaseType.valueOf("REBUTTAL")); assertNotNull(PhaseType.valueOf("SYNTHESIS")); + assertNotNull(PhaseType.valueOf("PLAN")); + assertNotNull(PhaseType.valueOf("EXECUTE")); + assertNotNull(PhaseType.valueOf("VERIFY")); } @Test void contextScope_allValues() { - assertEquals(5, ContextScope.values().length); + assertEquals(7, ContextScope.values().length); assertNotNull(ContextScope.valueOf("NONE")); assertNotNull(ContextScope.valueOf("FULL")); assertNotNull(ContextScope.valueOf("LAST_PHASE")); assertNotNull(ContextScope.valueOf("ANONYMOUS")); assertNotNull(ContextScope.valueOf("OWN_FEEDBACK")); + assertNotNull(ContextScope.valueOf("TASK_ONLY")); + assertNotNull(ContextScope.valueOf("TASK_WITH_DEPS")); } @Test @@ -234,4 +240,109 @@ void memberType_allValues() { assertNotNull(MemberType.valueOf("AGENT")); assertNotNull(MemberType.valueOf("GROUP")); } + + // ==================== LifecyclePolicy ==================== + + @Test + void lifecyclePolicy_toJson_allValues() { + assertEquals("ephemeral", LifecyclePolicy.EPHEMERAL.toJson()); + assertEquals("keep-deployed", LifecyclePolicy.KEEP_DEPLOYED.toJson()); + assertEquals("undeploy-only", LifecyclePolicy.UNDEPLOY_ONLY.toJson()); + assertEquals("agent-decides", LifecyclePolicy.AGENT_DECIDES.toJson()); + } + + @Test + void lifecyclePolicy_fromJson_validValues() { + assertEquals(LifecyclePolicy.EPHEMERAL, LifecyclePolicy.fromJson("ephemeral")); + assertEquals(LifecyclePolicy.KEEP_DEPLOYED, LifecyclePolicy.fromJson("keep-deployed")); + assertEquals(LifecyclePolicy.UNDEPLOY_ONLY, LifecyclePolicy.fromJson("undeploy-only")); + assertEquals(LifecyclePolicy.AGENT_DECIDES, LifecyclePolicy.fromJson("agent-decides")); + } + + @Test + void lifecyclePolicy_fromJson_null() { + assertEquals(LifecyclePolicy.EPHEMERAL, LifecyclePolicy.fromJson(null)); + } + + @Test + void lifecyclePolicy_fromJson_uppercase() { + assertEquals(LifecyclePolicy.EPHEMERAL, LifecyclePolicy.fromJson("EPHEMERAL")); + } + + @Test + void lifecyclePolicy_fromJson_invalid() { + assertThrows(IllegalArgumentException.class, + () -> LifecyclePolicy.fromJson("unknown-value")); + } + + // ==================== TaskDefinition ==================== + + @Test + void taskDefinition_fullConstructor() { + var td = new TaskDefinition("Subject", "Desc", "ROLE:analyst", List.of("dep1"), 2); + + assertEquals("Subject", td.subject()); + assertEquals("Desc", td.description()); + assertEquals("ROLE:analyst", td.assignToRole()); + assertEquals(List.of("dep1"), td.dependsOn()); + assertEquals(2, td.priority()); + } + + @Test + void taskDefinition_convenienceConstructor() { + var td = new TaskDefinition("Subject", "Desc"); + + assertEquals("Subject", td.subject()); + assertEquals("Desc", td.description()); + assertEquals("ALL", td.assignToRole()); + assertTrue(td.dependsOn().isEmpty()); + assertEquals(0, td.priority()); + } + + @Test + void taskDefinition_nullSubject_throws() { + assertThrows(NullPointerException.class, + () -> new TaskDefinition(null, "Desc")); + } + + @Test + void taskDefinition_nullDescription_throws() { + assertThrows(NullPointerException.class, + () -> new TaskDefinition("Subject", null)); + } + + @Test + void taskDefinition_nullDependsOn_defaultsToEmptyList() { + var td = new TaskDefinition("Subject", "Desc", "ALL", null, 0); + + assertNotNull(td.dependsOn()); + assertTrue(td.dependsOn().isEmpty()); + } + + @Test + void taskDefinition_nullAssignToRole_defaultsToALL() { + var td = new TaskDefinition("Subject", "Desc", null, List.of(), 0); + + assertEquals("ALL", td.assignToRole()); + } + + // ==================== DiscussionPhase ==================== + + @Test + void discussionPhase_requiresApprovalTrue() { + var phase = new DiscussionPhase( + "PHASE", PhaseType.OPINION, "ALL", + TurnOrder.SEQUENTIAL, ContextScope.FULL, false, null, 1, true); + + assertTrue(phase.requiresApproval()); + } + + @Test + void discussionPhase_requiresApprovalFalse() { + var phase = new DiscussionPhase( + "PHASE", PhaseType.OPINION, "ALL", + TurnOrder.SEQUENTIAL, ContextScope.FULL, false, null, 1, false); + + assertFalse(phase.requiresApproval()); + } } 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 6df5971d7..4f27111c3 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 @@ -246,4 +246,48 @@ void delphi_singleRound_producesOneOpinionAndSynthesis() { assertEquals(ContextScope.NONE, phases.get(0).contextScope()); assertEquals(PhaseType.SYNTHESIS, phases.get(1).type()); } + + // --- TASK_FORCE --- + + @Test + void taskForce_produces4Phases() { + List phases = DiscussionStylePresets.expand(DiscussionStyle.TASK_FORCE, 1); + assertEquals(4, phases.size()); + } + + @Test + void taskForce_phaseTypes() { + List phases = DiscussionStylePresets.expand(DiscussionStyle.TASK_FORCE, 1); + assertEquals(PhaseType.PLAN, phases.get(0).type()); + assertEquals(PhaseType.EXECUTE, phases.get(1).type()); + assertEquals(PhaseType.VERIFY, phases.get(2).type()); + assertEquals(PhaseType.SYNTHESIS, phases.get(3).type()); + } + + @Test + void taskForce_turnOrders() { + List phases = DiscussionStylePresets.expand(DiscussionStyle.TASK_FORCE, 1); + assertEquals(TurnOrder.SEQUENTIAL, phases.get(0).turnOrder()); // PLAN + assertEquals(TurnOrder.PARALLEL, phases.get(1).turnOrder()); // EXECUTE + assertEquals(TurnOrder.SEQUENTIAL, phases.get(2).turnOrder()); // VERIFY + assertEquals(TurnOrder.SEQUENTIAL, phases.get(3).turnOrder()); // SYNTHESIS + } + + @Test + void taskForce_contextScopes() { + List phases = DiscussionStylePresets.expand(DiscussionStyle.TASK_FORCE, 1); + assertEquals(ContextScope.FULL, phases.get(0).contextScope()); // PLAN + assertEquals(ContextScope.TASK_ONLY, phases.get(1).contextScope()); // EXECUTE + assertEquals(ContextScope.FULL, phases.get(2).contextScope()); // VERIFY + assertEquals(ContextScope.FULL, phases.get(3).contextScope()); // SYNTHESIS + } + + @Test + void taskForce_participants() { + List phases = DiscussionStylePresets.expand(DiscussionStyle.TASK_FORCE, 1); + assertEquals("MODERATOR", phases.get(0).participants()); // PLAN + assertEquals("ALL", phases.get(1).participants()); // EXECUTE + assertEquals("MODERATOR", phases.get(2).participants()); // VERIFY + assertEquals("MODERATOR", phases.get(3).participants()); // SYNTHESIS + } } 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 c238c95d8..dc85adde1 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 @@ -218,20 +218,24 @@ class EnumTests { @DisplayName("TranscriptEntryType — all values") void transcriptEntryTypes() { var values = TranscriptEntryType.values(); - assertEquals(11, values.length); + assertEquals(14, values.length); assertNotNull(TranscriptEntryType.valueOf("QUESTION")); assertNotNull(TranscriptEntryType.valueOf("SYNTHESIS")); assertNotNull(TranscriptEntryType.valueOf("SKIPPED")); + assertNotNull(TranscriptEntryType.valueOf("PLAN")); + assertNotNull(TranscriptEntryType.valueOf("TASK_RESULT")); + assertNotNull(TranscriptEntryType.valueOf("VERIFICATION")); } @Test @DisplayName("GroupConversationState — all values") void groupConversationStates() { var values = GroupConversationState.values(); - assertEquals(5, values.length); + assertEquals(6, values.length); assertNotNull(GroupConversationState.valueOf("CREATED")); assertNotNull(GroupConversationState.valueOf("COMPLETED")); assertNotNull(GroupConversationState.valueOf("FAILED")); + assertNotNull(GroupConversationState.valueOf("AWAITING_APPROVAL")); } } diff --git a/src/test/java/ai/labs/eddi/configs/groups/model/SharedTaskListTest.java b/src/test/java/ai/labs/eddi/configs/groups/model/SharedTaskListTest.java new file mode 100644 index 000000000..7be2a708b --- /dev/null +++ b/src/test/java/ai/labs/eddi/configs/groups/model/SharedTaskListTest.java @@ -0,0 +1,944 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.configs.groups.model; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link SharedTaskList} — status transitions, dependency + * queries, agent filtering, and cycle detection. + * + * @author ginccc + */ +class SharedTaskListTest { + + private SharedTaskList list; + + @BeforeEach + void setUp() { + list = new SharedTaskList(); + } + + // --- 1. Basic add --- + + @Test + void addTask_generatesId() { + var task = list.addTask(new SharedTaskList.TaskItem("Write report", "Detailed report", 1)); + + assertNotNull(task.id(), "Task ID must be generated"); + assertEquals("Write report", task.subject()); + assertEquals(SharedTaskList.TaskStatus.PENDING, task.status()); + } + + // --- 2–6. Happy-path transitions --- + + @Test + void assignTask_pendingToAssigned() { + var task = list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + + var assigned = list.assignTask(task.id(), "agent-1", "Agent One"); + + assertEquals(SharedTaskList.TaskStatus.ASSIGNED, assigned.status()); + assertEquals("agent-1", assigned.assignedAgentId()); + assertEquals("Agent One", assigned.assignedDisplayName()); + } + + @Test + void startTask_assignedToInProgress() { + var task = list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + list.assignTask(task.id(), "agent-1", "Agent One"); + + var started = list.startTask(task.id()); + + assertEquals(SharedTaskList.TaskStatus.IN_PROGRESS, started.status()); + } + + @Test + void completeTask_setsResult() { + var task = list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + list.assignTask(task.id(), "agent-1", "Agent One"); + list.startTask(task.id()); + + var completed = list.completeTask(task.id(), "All done successfully"); + + assertEquals(SharedTaskList.TaskStatus.COMPLETED, completed.status()); + assertEquals("All done successfully", completed.result()); + assertNotNull(completed.completedAt(), "completedAt must be set"); + } + + @Test + void verifyTask_passed() { + var task = list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + list.assignTask(task.id(), "agent-1", "Agent One"); + list.startTask(task.id()); + list.completeTask(task.id(), "result"); + + var verified = list.verifyTask(task.id(), true, "Looks good"); + + assertEquals(SharedTaskList.TaskStatus.VERIFIED, verified.status()); + assertTrue(verified.verified()); + assertEquals("Looks good", verified.verificationNote()); + } + + @Test + void verifyTask_failed() { + var task = list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + list.assignTask(task.id(), "agent-1", "Agent One"); + list.startTask(task.id()); + list.completeTask(task.id(), "result"); + + var failed = list.verifyTask(task.id(), false, "Insufficient quality"); + + assertEquals(SharedTaskList.TaskStatus.FAILED, failed.status()); + assertFalse(failed.verified()); + assertEquals("Insufficient quality", failed.verificationNote()); + } + + // --- 7–9. failTask from various states --- + + @Test + void failTask_fromPending() { + var task = list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + + var failed = list.failTask(task.id(), "No longer needed"); + + assertEquals(SharedTaskList.TaskStatus.FAILED, failed.status()); + assertEquals("No longer needed", failed.verificationNote()); + } + + @Test + void failTask_fromAssigned() { + var task = list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + list.assignTask(task.id(), "agent-1", "Agent One"); + + var failed = list.failTask(task.id(), "Agent unavailable"); + + assertEquals(SharedTaskList.TaskStatus.FAILED, failed.status()); + } + + @Test + void failTask_fromInProgress() { + var task = list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + list.assignTask(task.id(), "agent-1", "Agent One"); + list.startTask(task.id()); + + var failed = list.failTask(task.id(), "Runtime error"); + + assertEquals(SharedTaskList.TaskStatus.FAILED, failed.status()); + } + + // --- 10–12. Invalid transitions --- + + @Test + void invalidTransition_completingPending() { + var task = list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + + assertThrows(IllegalStateException.class, + () -> list.completeTask(task.id(), "result"), + "Completing a PENDING task must throw"); + } + + @Test + void invalidTransition_startingCompleted() { + var task = list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + list.assignTask(task.id(), "agent-1", "Agent One"); + list.startTask(task.id()); + list.completeTask(task.id(), "done"); + + assertThrows(IllegalStateException.class, + () -> list.startTask(task.id()), + "Starting a COMPLETED task must throw"); + } + + @Test + void invalidTransition_failVerified() { + var task = list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + list.assignTask(task.id(), "agent-1", "Agent One"); + list.startTask(task.id()); + list.completeTask(task.id(), "done"); + list.verifyTask(task.id(), true, "OK"); + + assertThrows(IllegalStateException.class, + () -> list.failTask(task.id(), "too late"), + "Failing a VERIFIED task must throw"); + } + + // --- 13–15. findExecutableTasks --- + + @Test + void findExecutableTasks_noDeps() { + list.addTask(new SharedTaskList.TaskItem("Task 1", "desc", 0)); + list.addTask(new SharedTaskList.TaskItem("Task 2", "desc", 1)); + var task3 = list.addTask(new SharedTaskList.TaskItem("Task 3", "desc", 2)); + list.assignTask(task3.id(), "agent-1", "Agent One"); + + // Also assign task 1 to have a mix of PENDING and ASSIGNED + var task1 = list.findById(list.all().getFirst().id()); + list.assignTask(task1.id(), "agent-2", "Agent Two"); + + var executable = list.findExecutableTasks(); + + assertEquals(3, executable.size(), + "All 3 tasks (1 PENDING + 2 ASSIGNED, no deps) should be executable"); + } + + @Test + void findExecutableTasks_withDeps_allSatisfied() { + var taskA = list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + + // Task B depends on Task A + var taskBItem = new SharedTaskList.TaskItem( + UUID.randomUUID().toString(), "Task B", "desc", + SharedTaskList.TaskStatus.PENDING, null, null, + List.of(taskA.id()), null, null, false, 1, Instant.now(), null); + list.addTask(taskBItem); + + // Complete Task A through the full lifecycle + list.assignTask(taskA.id(), "agent-1", "Agent One"); + list.startTask(taskA.id()); + list.completeTask(taskA.id(), "done"); + + var executable = list.findExecutableTasks(); + + // Task A is COMPLETED (not executable), Task B's deps are satisfied → + // executable + assertEquals(1, executable.size()); + assertEquals("Task B", executable.getFirst().subject()); + } + + @Test + void findExecutableTasks_withDeps_unsatisfied() { + var taskA = list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + + // Task B depends on Task A, which is still PENDING + var taskBItem = new SharedTaskList.TaskItem( + UUID.randomUUID().toString(), "Task B", "desc", + SharedTaskList.TaskStatus.PENDING, null, null, + List.of(taskA.id()), null, null, false, 1, Instant.now(), null); + list.addTask(taskBItem); + + var executable = list.findExecutableTasks(); + + // Task A is executable (PENDING, no deps), Task B is blocked + assertEquals(1, executable.size()); + assertEquals("Task A", executable.getFirst().subject()); + } + + // --- 16. findTasksForAgent --- + + @Test + void findTasksForAgent() { + var t1 = list.addTask(new SharedTaskList.TaskItem("Task 1", "desc", 0)); + var t2 = list.addTask(new SharedTaskList.TaskItem("Task 2", "desc", 1)); + var t3 = list.addTask(new SharedTaskList.TaskItem("Task 3", "desc", 2)); + + list.assignTask(t1.id(), "agent-alpha", "Alpha"); + list.assignTask(t2.id(), "agent-beta", "Beta"); + list.assignTask(t3.id(), "agent-alpha", "Alpha"); + + var alphaTasks = list.findTasksForAgent("agent-alpha"); + var betaTasks = list.findTasksForAgent("agent-beta"); + + assertEquals(2, alphaTasks.size(), "agent-alpha should have 2 tasks"); + assertEquals(1, betaTasks.size(), "agent-beta should have 1 task"); + assertTrue(alphaTasks.stream().allMatch(t -> "agent-alpha".equals(t.assignedAgentId()))); + } + + // --- 17–18. Cycle detection --- + + @Test + void detectCycles_noCycle() { + var taskA = list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + + var taskBItem = new SharedTaskList.TaskItem( + UUID.randomUUID().toString(), "Task B", "desc", + SharedTaskList.TaskStatus.PENDING, null, null, + List.of(taskA.id()), null, null, false, 1, Instant.now(), null); + var taskB = list.addTask(taskBItem); + + var taskCItem = new SharedTaskList.TaskItem( + UUID.randomUUID().toString(), "Task C", "desc", + SharedTaskList.TaskStatus.PENDING, null, null, + List.of(taskB.id()), null, null, false, 2, Instant.now(), null); + list.addTask(taskCItem); + + var cycles = list.detectCycles(); + + assertTrue(cycles.isEmpty(), "A→B→C (linear chain) should have no cycles"); + } + + @Test + void detectCycles_simpleCycle() { + String idA = UUID.randomUUID().toString(); + String idB = UUID.randomUUID().toString(); + + // Task A depends on Task B + var taskAItem = new SharedTaskList.TaskItem( + idA, "Task A", "desc", + SharedTaskList.TaskStatus.PENDING, null, null, + List.of(idB), null, null, false, 0, Instant.now(), null); + + // Task B depends on Task A → cycle + var taskBItem = new SharedTaskList.TaskItem( + idB, "Task B", "desc", + SharedTaskList.TaskStatus.PENDING, null, null, + List.of(idA), null, null, false, 1, Instant.now(), null); + + list.addTask(taskAItem); + list.addTask(taskBItem); + + var cycles = list.detectCycles(); + + assertFalse(cycles.isEmpty(), "A↔B mutual dependency must be detected as a cycle"); + } + + // --- Additional edge cases (code review gap coverage) --- + + @Test + void findById_null_returnsNull() { + list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + assertNull(list.findById(null)); + } + + @Test + void findById_nonexistent_returnsNull() { + list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + assertNull(list.findById("does-not-exist")); + } + + @Test + void assignTask_nonexistentId_throws() { + assertThrows(IllegalArgumentException.class, + () -> list.assignTask("nonexistent", "agent-1", "Agent One")); + } + + @Test + void startTask_nonexistentId_throws() { + assertThrows(IllegalArgumentException.class, + () -> list.startTask("nonexistent")); + } + + @Test + void isAllComplete_allVerified_returnsTrue() { + var task = list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + list.assignTask(task.id(), "a1", "A1"); + list.startTask(task.id()); + list.completeTask(task.id(), "done"); + list.verifyTask(task.id(), true, "ok"); + + assertTrue(list.all().stream().allMatch( + t -> t.status() == SharedTaskList.TaskStatus.VERIFIED + || t.status() == SharedTaskList.TaskStatus.COMPLETED)); + } + + @Test + void isAllComplete_mixedStates_includingFailed() { + var t1 = list.addTask(new SharedTaskList.TaskItem("T1", "desc", 0)); + var t2 = list.addTask(new SharedTaskList.TaskItem("T2", "desc", 1)); + + list.assignTask(t1.id(), "a1", "A1"); + list.startTask(t1.id()); + list.completeTask(t1.id(), "done"); + + list.failTask(t2.id(), "too hard"); + + assertEquals(SharedTaskList.TaskStatus.COMPLETED, list.findById(t1.id()).status()); + assertEquals(SharedTaskList.TaskStatus.FAILED, list.findById(t2.id()).status()); + } + + @Test + void findExecutableTasks_satisfiedByVerified() { + String idA = UUID.randomUUID().toString(); + String idB = UUID.randomUUID().toString(); + + var taskA = new SharedTaskList.TaskItem( + idA, "A", "desc", SharedTaskList.TaskStatus.PENDING, + null, null, List.of(), null, null, false, 0, Instant.now(), null); + var taskB = new SharedTaskList.TaskItem( + idB, "B", "desc", SharedTaskList.TaskStatus.PENDING, + null, null, List.of(idA), null, null, false, 1, Instant.now(), null); + + list.addTask(taskA); + list.addTask(taskB); + + // B not executable (A not complete) + assertEquals(1, list.findExecutableTasks().size()); + + // Complete and verify A + list.assignTask(idA, "agent-1", "Agent One"); + list.startTask(idA); + list.completeTask(idA, "result"); + list.verifyTask(idA, true, "good"); + + // B should now be executable (dependency A is VERIFIED) + var executableIds = list.findExecutableTasks().stream().map(SharedTaskList.TaskItem::id).toList(); + assertTrue(executableIds.contains(idB), "B should be executable after A is VERIFIED"); + } + + @Test + void multipleDependencies_allMustBeSatisfied() { + String idA = UUID.randomUUID().toString(); + String idB = UUID.randomUUID().toString(); + String idC = UUID.randomUUID().toString(); + + var taskA = new SharedTaskList.TaskItem(idA, "A", "desc", + SharedTaskList.TaskStatus.PENDING, null, null, List.of(), null, null, false, 0, Instant.now(), null); + var taskB = new SharedTaskList.TaskItem(idB, "B", "desc", + SharedTaskList.TaskStatus.PENDING, null, null, List.of(), null, null, false, 0, Instant.now(), null); + var taskC = new SharedTaskList.TaskItem(idC, "C", "desc", + SharedTaskList.TaskStatus.PENDING, null, null, List.of(idA, idB), null, null, false, 0, Instant.now(), null); + + list.addTask(taskA); + list.addTask(taskB); + list.addTask(taskC); + + // Only A and B executable initially + assertEquals(2, list.findExecutableTasks().size()); + + // Complete A only — C still blocked (B not done) + list.assignTask(idA, "a1", "A1"); + list.startTask(idA); + list.completeTask(idA, "done"); + assertFalse(list.findExecutableTasks().stream().anyMatch(t -> t.id().equals(idC))); + + // Complete B — C now executable + list.assignTask(idB, "a2", "A2"); + list.startTask(idB); + list.completeTask(idB, "done"); + assertTrue(list.findExecutableTasks().stream().anyMatch(t -> t.id().equals(idC))); + } + + @Test + void all_returnsDefensiveCopy() { + list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + var snapshot = list.all(); + list.addTask(new SharedTaskList.TaskItem("Task B", "desc", 1)); + + // snapshot should not reflect the later addition + assertEquals(1, snapshot.size()); + assertEquals(2, list.all().size()); + } + + @Test + void detectCycles_selfReferencing() { + String id = UUID.randomUUID().toString(); + var selfRef = new SharedTaskList.TaskItem( + id, "Self", "depends on itself", + SharedTaskList.TaskStatus.PENDING, null, null, + List.of(id), null, null, false, 0, Instant.now(), null); + list.addTask(selfRef); + + var cycles = list.detectCycles(); + assertFalse(cycles.isEmpty(), "Self-referencing dependency must be detected"); + } + + @Test + void concurrentModifications_doNotCorrupt() throws Exception { + // Add 100 tasks + for (int i = 0; i < 100; i++) { + list.addTask(new SharedTaskList.TaskItem("Task " + i, "desc", i)); + } + + // Concurrently assign + start + complete from multiple threads + var tasks = list.all(); + var futures = new java.util.ArrayList>(); + for (var task : tasks) { + futures.add(java.util.concurrent.CompletableFuture.runAsync(() -> { + try { + list.assignTask(task.id(), "agent-" + task.priority(), "Agent " + task.priority()); + list.startTask(task.id()); + list.completeTask(task.id(), "done-" + task.priority()); + } catch (Exception e) { + // Some concurrent attempts may get state errors — that's fine + } + })); + } + + java.util.concurrent.CompletableFuture.allOf(futures.toArray(new java.util.concurrent.CompletableFuture[0])).get(10, + java.util.concurrent.TimeUnit.SECONDS); + + // No corruption: all tasks should still be accessible + assertEquals(100, list.size(), "All 100 tasks should survive concurrent access"); + } + + // --- updateTask regression tests (C1 fix: addTask→updateTask for deps) --- + + @Test + void updateTask_replacesExistingTask() { + var task = list.addTask(new SharedTaskList.TaskItem("Task A", "desc", 0)); + var updated = new SharedTaskList.TaskItem( + task.id(), "Task A", "updated desc", + SharedTaskList.TaskStatus.PENDING, null, null, + List.of(), null, null, false, 5, Instant.now(), null); + + list.updateTask(updated); + + assertEquals(1, list.size(), "updateTask must not duplicate"); + assertEquals("updated desc", list.findById(task.id()).description()); + assertEquals(5, list.findById(task.id()).priority()); + } + + @Test + void updateTask_nonexistentId_throws() { + var orphan = new SharedTaskList.TaskItem( + "nonexistent-id", "X", "desc", + SharedTaskList.TaskStatus.PENDING, null, null, + List.of(), null, null, false, 0, Instant.now(), null); + + assertThrows(IllegalArgumentException.class, () -> list.updateTask(orphan)); + } + + @Test + void updateTask_addsDependencies_blocksExecution() { + String idA = UUID.randomUUID().toString(); + String idB = UUID.randomUUID().toString(); + + var taskA = new SharedTaskList.TaskItem( + idA, "A", "desc", SharedTaskList.TaskStatus.PENDING, + null, null, List.of(), null, null, false, 0, Instant.now(), null); + var taskB = new SharedTaskList.TaskItem( + idB, "B", "desc", SharedTaskList.TaskStatus.PENDING, + null, null, List.of(), null, null, false, 1, Instant.now(), null); + + list.addTask(taskA); + list.addTask(taskB); + + // Both executable initially (no deps) + assertEquals(2, list.findExecutableTasks().size()); + + // Update B to depend on A + var taskBWithDep = new SharedTaskList.TaskItem( + idB, "B", "desc", SharedTaskList.TaskStatus.PENDING, + null, null, List.of(idA), null, null, false, 1, Instant.now(), null); + list.updateTask(taskBWithDep); + + // Only A should be executable now + var executable = list.findExecutableTasks(); + assertEquals(1, executable.size(), "Only A should be executable after B gains dep on A"); + assertEquals(idA, executable.getFirst().id()); + + // List still has exactly 2 tasks + assertEquals(2, list.size(), "updateTask must not change list size"); + } + + // --- Additional branch coverage --- + + @Test + void findTasksForAgent_null_returnsEmpty() { + list.addTask(new SharedTaskList.TaskItem("Task", "desc", 0)); + assertTrue(list.findTasksForAgent(null).isEmpty()); + } + + @Test + void assignTask_fromAssigned_throws() { + var task = list.addTask(new SharedTaskList.TaskItem("Task", "desc", 0)); + list.assignTask(task.id(), "agent-1", "Agent"); + + // Trying to assign again should throw (already ASSIGNED) + assertThrows(IllegalStateException.class, + () -> list.assignTask(task.id(), "agent-2", "Agent2")); + } + + @Test + void completeTask_nonexistentId_throws() { + assertThrows(IllegalArgumentException.class, + () -> list.completeTask("nonexistent", "result")); + } + + @Test + void verifyTask_nonexistentId_throws() { + assertThrows(IllegalArgumentException.class, + () -> list.verifyTask("nonexistent", true, "note")); + } + + @Test + void verifyTask_fromPending_throws() { + var task = list.addTask(new SharedTaskList.TaskItem("Task", "desc", 0)); + assertThrows(IllegalStateException.class, + () -> list.verifyTask(task.id(), true, "note")); + } + + @Test + void failTask_nonexistentId_throws() { + assertThrows(IllegalArgumentException.class, + () -> list.failTask("nonexistent", "reason")); + } + + @Test + void failTask_fromCompleted() { + var task = list.addTask(new SharedTaskList.TaskItem("Task", "desc", 0)); + list.assignTask(task.id(), "agent-1", "Agent"); + list.startTask(task.id()); + list.completeTask(task.id(), "done"); + + var failed = list.failTask(task.id(), "actually wrong"); + assertEquals(SharedTaskList.TaskStatus.FAILED, failed.status()); + } + + @Test + void failTask_fromFailed_throws() { + var task = list.addTask(new SharedTaskList.TaskItem("Task", "desc", 0)); + list.assignTask(task.id(), "agent-1", "Agent"); + list.startTask(task.id()); + list.failTask(task.id(), "failed"); + + // Already failed — can't fail again + assertThrows(IllegalStateException.class, + () -> list.failTask(task.id(), "fail again")); + } + + @Test + void setTasks_null_createsEmptyList() { + list.addTask(new SharedTaskList.TaskItem("Task", "desc", 0)); + assertFalse(list.isEmpty()); + + list.setTasks(null); + assertTrue(list.isEmpty()); + assertEquals(0, list.size()); + } + + @Test + void sizeAndIsEmpty_basicOperations() { + assertTrue(list.isEmpty()); + assertEquals(0, list.size()); + + list.addTask(new SharedTaskList.TaskItem("Task", "desc", 0)); + assertFalse(list.isEmpty()); + assertEquals(1, list.size()); + } + + @Test + void findExecutableTasks_withNonexistentDep_notExecutable() { + var id = UUID.randomUUID().toString(); + var task = new SharedTaskList.TaskItem( + id, "Task", "desc", SharedTaskList.TaskStatus.PENDING, + null, null, List.of("nonexistent-dep-id"), null, null, false, 0, Instant.now(), null); + list.addTask(task); + + // Task depends on a nonexistent ID — should NOT be executable + assertTrue(list.findExecutableTasks().isEmpty()); + } + + @Test + void failTask_fromVerified_throws() { + var task = list.addTask(new SharedTaskList.TaskItem("Task", "desc", 0)); + list.assignTask(task.id(), "agent-1", "Agent"); + list.startTask(task.id()); + list.completeTask(task.id(), "done"); + list.verifyTask(task.id(), true, "good"); + + // VERIFIED is terminal — can't fail + assertThrows(IllegalStateException.class, + () -> list.failTask(task.id(), "no")); + } + + // ========================================================================= + // Concurrency / Deadlock Tests + // ========================================================================= + + /** + * Verifies that findExecutableTasks (which internally calls findById via + * allDependenciesSatisfied) does not deadlock due to reentrant locking. The + * synchronized findExecutableTasks calls allDependenciesSatisfied which calls + * findById (also synchronized) — reentrant, but worth proving. + */ + @Test + void findExecutableTasks_withDependencies_noReentrantDeadlock() throws Exception { + // Set up a dependency chain: task2 depends on task1 + var task1 = list.addTask(new SharedTaskList.TaskItem("Task 1", "desc", 0)); + var task2 = list.addTask(new SharedTaskList.TaskItem( + UUID.randomUUID().toString(), "Task 2", "depends on task1", + SharedTaskList.TaskStatus.PENDING, null, null, + List.of(task1.id()), null, null, false, 0, Instant.now(), null)); + list.updateTask(task2); + + // If reentrant locking fails, this would deadlock. + // Use a timeout to detect deadlock rather than hanging forever. + var future = java.util.concurrent.CompletableFuture.supplyAsync(() -> list.findExecutableTasks()); + var result = future.get(5, java.util.concurrent.TimeUnit.SECONDS); + + // task1 has no deps → executable; task2 depends on uncompleted task1 → not + // executable + assertEquals(1, result.size()); + assertEquals(task1.id(), result.get(0).id()); + } + + /** + * Verifies that detectCycles (synchronized) calling findById (also + * synchronized) inside DFS does not deadlock. + */ + @Test + void detectCycles_noReentrantDeadlock() throws Exception { + // Create tasks with dependencies that form a chain (no cycle) + var task1 = list.addTask(new SharedTaskList.TaskItem("Task 1", "desc", 0)); + var task2Id = UUID.randomUUID().toString(); + var task2 = new SharedTaskList.TaskItem( + task2Id, "Task 2", "depends on task1", + SharedTaskList.TaskStatus.PENDING, null, null, + List.of(task1.id()), null, null, false, 0, Instant.now(), null); + list.addTask(task2); + + var future = java.util.concurrent.CompletableFuture.supplyAsync(() -> list.detectCycles()); + var result = future.get(5, java.util.concurrent.TimeUnit.SECONDS); + assertTrue(result.isEmpty(), "No cycles expected"); + } + + /** + * Stress test: multiple threads performing lifecycle transitions on different + * tasks simultaneously. Verifies no deadlock and no data corruption. + */ + @Test + void parallelLifecycleTransitions_noDeadlockOrCorruption() throws Exception { + int taskCount = 50; + var latch = new java.util.concurrent.CountDownLatch(1); + var errors = new java.util.concurrent.CopyOnWriteArrayList(); + var threads = new java.util.ArrayList(); + + // Pre-populate tasks + var taskIds = new java.util.ArrayList(); + for (int i = 0; i < taskCount; i++) { + var task = list.addTask(new SharedTaskList.TaskItem("Task " + i, "desc " + i, i)); + taskIds.add(task.id()); + } + + // Each thread drives one task through the full lifecycle + for (String taskId : taskIds) { + threads.add(Thread.ofVirtual().start(() -> { + try { + latch.await(); // All threads start simultaneously + list.assignTask(taskId, "agent-" + taskId, "Agent"); + list.startTask(taskId); + list.completeTask(taskId, "result-" + taskId); + list.verifyTask(taskId, true, "ok"); + } catch (Throwable t) { + errors.add(t); + } + })); + } + + // Release all threads at once for maximum contention + latch.countDown(); + for (var thread : threads) { + thread.join(10_000); // 10s timeout — deadlock detection + assertFalse(thread.isAlive(), "Thread should have completed — possible deadlock"); + } + + assertTrue(errors.isEmpty(), + "Unexpected errors during parallel transitions: " + errors); + + // All tasks should be VERIFIED + for (String taskId : taskIds) { + var task = list.findById(taskId); + assertEquals(SharedTaskList.TaskStatus.VERIFIED, task.status(), + "Task " + taskId + " should be VERIFIED"); + } + } + + /** + * Stress test: interleaved read and write operations. Readers call + * findExecutableTasks/all/size while writers add and transition tasks. + */ + @Test + void concurrentReadWriteInterleaving_noDeadlock() throws Exception { + var latch = new java.util.concurrent.CountDownLatch(1); + var errors = new java.util.concurrent.CopyOnWriteArrayList(); + int writerCount = 20; + int readerCount = 20; + var threads = new java.util.ArrayList(); + + // Writers: add tasks and transition them + for (int i = 0; i < writerCount; i++) { + final int idx = i; + threads.add(Thread.ofVirtual().start(() -> { + try { + latch.await(); + var task = list.addTask(new SharedTaskList.TaskItem("Task " + idx, "desc", idx)); + list.assignTask(task.id(), "agent-" + idx, "Agent " + idx); + list.startTask(task.id()); + list.completeTask(task.id(), "done " + idx); + } catch (Throwable t) { + errors.add(t); + } + })); + } + + // Readers: continuously query the list + for (int i = 0; i < readerCount; i++) { + threads.add(Thread.ofVirtual().start(() -> { + try { + latch.await(); + for (int j = 0; j < 100; j++) { + list.findExecutableTasks(); + list.all(); + list.size(); + list.isEmpty(); + } + } catch (Throwable t) { + errors.add(t); + } + })); + } + + latch.countDown(); + for (var thread : threads) { + thread.join(10_000); + assertFalse(thread.isAlive(), "Thread should have completed — possible deadlock"); + } + + assertTrue(errors.isEmpty(), + "Unexpected errors during concurrent R/W: " + errors); + assertEquals(writerCount, list.size()); + } + + /** + * Two threads race to assign the same PENDING task. Exactly one should succeed + * and one should fail (the task is no longer PENDING after the first assign). + */ + @Test + void raceCondition_doubleAssign_exactlyOneWins() throws Exception { + var task = list.addTask(new SharedTaskList.TaskItem("Contested Task", "desc", 0)); + var latch = new java.util.concurrent.CountDownLatch(1); + var successes = new java.util.concurrent.atomic.AtomicInteger(0); + var expectedFailures = new java.util.concurrent.atomic.AtomicInteger(0); + var unexpectedErrors = new java.util.concurrent.CopyOnWriteArrayList(); + + var t1 = Thread.ofVirtual().start(() -> { + try { + latch.await(); + list.assignTask(task.id(), "agent-1", "Agent 1"); + successes.incrementAndGet(); + } catch (IllegalStateException e) { + expectedFailures.incrementAndGet(); + } catch (Throwable t) { + unexpectedErrors.add(t); + } + }); + + var t2 = Thread.ofVirtual().start(() -> { + try { + latch.await(); + list.assignTask(task.id(), "agent-2", "Agent 2"); + successes.incrementAndGet(); + } catch (IllegalStateException e) { + expectedFailures.incrementAndGet(); + } catch (Throwable t) { + unexpectedErrors.add(t); + } + }); + + latch.countDown(); + t1.join(5000); + t2.join(5000); + + assertTrue(unexpectedErrors.isEmpty(), + "Unexpected errors during race: " + unexpectedErrors); + assertEquals(1, successes.get(), "Exactly one thread should win the assignment"); + assertEquals(1, expectedFailures.get(), "Exactly one thread should fail with IllegalStateException"); + + var updated = list.findById(task.id()); + assertEquals(SharedTaskList.TaskStatus.ASSIGNED, updated.status()); + } + + /** + * Verifies that getTasks() returns a defensive copy, so callers cannot corrupt + * internal state by modifying the returned list. + */ + @Test + void getTasks_returnsDefensiveCopy_internalStateIsolated() { + list.addTask(new SharedTaskList.TaskItem("Task 1", "desc", 0)); + list.addTask(new SharedTaskList.TaskItem("Task 2", "desc", 1)); + + var copy = list.getTasks(); + assertEquals(2, copy.size()); + + // Modifying the returned list must NOT affect internal state + int sizeBefore = list.size(); + copy.add(new SharedTaskList.TaskItem("Sneaky Task", "injected", 99)); + assertEquals(sizeBefore, list.size(), + "getTasks() should return a defensive copy — internal state must be unchanged"); + } + + /** + * Stress test: concurrent lifecycle transitions mixed with findExecutableTasks + * calls that trigger allDependenciesSatisfied → findById reentrant path. This + * is the most realistic contention scenario from GroupConversationService. + */ + @Test + void realisticContention_lifecycleWithDependencyQueries() throws Exception { + // Create a realistic task graph: 5 root tasks, 5 dependent tasks + var rootIds = new java.util.ArrayList(); + for (int i = 0; i < 5; i++) { + var task = list.addTask(new SharedTaskList.TaskItem("Root " + i, "desc", 0)); + rootIds.add(task.id()); + } + for (int i = 0; i < 5; i++) { + var depTask = new SharedTaskList.TaskItem( + UUID.randomUUID().toString(), "Dep " + i, "depends on root " + i, + SharedTaskList.TaskStatus.PENDING, null, null, + List.of(rootIds.get(i)), null, null, false, 1, Instant.now(), null); + list.addTask(depTask); + } + + var latch = new java.util.concurrent.CountDownLatch(1); + var errors = new java.util.concurrent.CopyOnWriteArrayList(); + var threads = new java.util.ArrayList(); + + // 5 threads drive root tasks through lifecycle + for (String rootId : rootIds) { + threads.add(Thread.ofVirtual().start(() -> { + try { + latch.await(); + list.assignTask(rootId, "agent-root", "Root Agent"); + list.startTask(rootId); + // Interleave queries between transitions + list.findExecutableTasks(); + list.completeTask(rootId, "done"); + list.findExecutableTasks(); // Should now unlock dependent tasks + } catch (Throwable t) { + errors.add(t); + } + })); + } + + // 5 query threads hammering findExecutableTasks and detectCycles + for (int i = 0; i < 5; i++) { + threads.add(Thread.ofVirtual().start(() -> { + try { + latch.await(); + for (int j = 0; j < 200; j++) { + list.findExecutableTasks(); + if (j % 20 == 0) { + list.detectCycles(); + } + } + } catch (Throwable t) { + errors.add(t); + } + })); + } + + latch.countDown(); + for (var thread : threads) { + thread.join(15_000); + assertFalse(thread.isAlive(), "Thread should have completed — possible deadlock"); + } + + assertTrue(errors.isEmpty(), + "Unexpected errors during realistic contention: " + errors); + + // All root tasks should be COMPLETED + for (String rootId : rootIds) { + var task = list.findById(rootId); + assertEquals(SharedTaskList.TaskStatus.COMPLETED, task.status()); + } + } +} diff --git a/src/test/java/ai/labs/eddi/engine/internal/DynamicAgentTrackingPropagationTest.java b/src/test/java/ai/labs/eddi/engine/internal/DynamicAgentTrackingPropagationTest.java new file mode 100644 index 000000000..820e548fb --- /dev/null +++ b/src/test/java/ai/labs/eddi/engine/internal/DynamicAgentTrackingPropagationTest.java @@ -0,0 +1,420 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.engine.internal; + +import ai.labs.eddi.configs.groups.model.GroupConversation; +import ai.labs.eddi.engine.memory.model.SimpleConversationMemorySnapshot; +import ai.labs.eddi.engine.memory.model.SimpleConversationMemorySnapshot.ConversationStepData; +import ai.labs.eddi.engine.memory.model.SimpleConversationMemorySnapshot.SimpleConversationStep; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link GroupConversationService#propagateDynamicAgentTracking} + * which bridges dynamic agent lifecycle tracking between AgentOrchestrator's + * per-turn tool-local lists and GroupConversation's lifecycle tracking. + * + *

+ * These tests verify regression safety for three critical fixes: + *

    + *
  1. Snapshot must include dynamic:* keys (returnDetailed=true)
  2. + *
  3. Set reference must not be copied (retained IDs visibility)
  4. + *
  5. Propagation correctly merges IDs into GroupConversation
  6. + *
+ * + * @author ginccc + */ +class DynamicAgentTrackingPropagationTest { + + /** Well-known data keys — must match AgentOrchestrator constants. */ + private static final String KEY_CREATED = "dynamic:created_agent_ids"; + private static final String KEY_RETAINED = "dynamic:retained_agent_ids"; + + private GroupConversation gc; + + @BeforeEach + void setUp() { + gc = new GroupConversation(); + } + + // ========================================================================= + // Null / Empty Guards + // ========================================================================= + + @Nested + @DisplayName("Null and empty guards") + class NullGuards { + + @Test + @DisplayName("null snapshot → no-op, no exception") + void nullSnapshot_noOp() { + GroupConversationService.propagateDynamicAgentTracking(null, gc); + assertTrue(gc.getCreatedAgentIds().isEmpty()); + assertTrue(gc.getRetainedAgentIds().isEmpty()); + } + + @Test + @DisplayName("snapshot with null conversationSteps → no-op") + void nullConversationSteps_noOp() { + var snapshot = new SimpleConversationMemorySnapshot(); + snapshot.setConversationSteps(null); + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + assertTrue(gc.getCreatedAgentIds().isEmpty()); + } + + @Test + @DisplayName("snapshot with empty conversationSteps → no-op") + void emptyConversationSteps_noOp() { + var snapshot = new SimpleConversationMemorySnapshot(); + snapshot.setConversationSteps(new LinkedList<>()); + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + assertTrue(gc.getCreatedAgentIds().isEmpty()); + } + + @Test + @DisplayName("last step is null → no-op") + void nullLastStep_noOp() { + var snapshot = new SimpleConversationMemorySnapshot(); + var steps = new LinkedList(); + steps.add(null); + snapshot.setConversationSteps(steps); + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + assertTrue(gc.getCreatedAgentIds().isEmpty()); + } + + @Test + @DisplayName("last step has null conversationStep list → no-op") + void nullStepDataList_noOp() { + var snapshot = new SimpleConversationMemorySnapshot(); + var step = new SimpleConversationStep(); + step.setConversationStep(null); + snapshot.getConversationSteps().add(step); + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + assertTrue(gc.getCreatedAgentIds().isEmpty()); + } + + @Test + @DisplayName("stepData with null key → skipped gracefully") + void nullKeyInStepData_skipped() { + var snapshot = buildSnapshot( + new ConversationStepData(null, List.of("agent-1"), null, null)); + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + assertTrue(gc.getCreatedAgentIds().isEmpty()); + } + + @Test + @DisplayName("null stepData entry → skipped gracefully") + void nullStepDataEntry_skipped() { + var snapshot = new SimpleConversationMemorySnapshot(); + var step = new SimpleConversationStep(); + var dataList = new LinkedList(); + dataList.add(null); + dataList.add(new ConversationStepData(KEY_CREATED, List.of("agent-1"), null, null)); + step.setConversationStep(dataList); + snapshot.getConversationSteps().add(step); + + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + assertEquals(1, gc.getCreatedAgentIds().size()); + assertTrue(gc.getCreatedAgentIds().contains("agent-1")); + } + } + + // ========================================================================= + // Created Agent IDs Propagation + // ========================================================================= + + @Nested + @DisplayName("Created agent IDs propagation") + class CreatedAgentIds { + + @Test + @DisplayName("propagates created agent IDs from snapshot to GroupConversation") + void propagatesCreatedIds() { + var snapshot = buildSnapshot( + new ConversationStepData(KEY_CREATED, + new CopyOnWriteArrayList<>(List.of("agent-a", "agent-b")), + null, null)); + + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + + assertEquals(2, gc.getCreatedAgentIds().size()); + assertTrue(gc.getCreatedAgentIds().contains("agent-a")); + assertTrue(gc.getCreatedAgentIds().contains("agent-b")); + } + + @Test + @DisplayName("deduplicates created IDs already present in GroupConversation") + void deduplicatesExistingIds() { + gc.getCreatedAgentIds().add("agent-a"); // already present + + var snapshot = buildSnapshot( + new ConversationStepData(KEY_CREATED, + List.of("agent-a", "agent-b"), null, null)); + + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + + // agent-a should NOT be duplicated + assertEquals(2, gc.getCreatedAgentIds().size()); + assertEquals(1, gc.getCreatedAgentIds().stream() + .filter("agent-a"::equals).count()); + } + + @Test + @DisplayName("handles empty created IDs list") + void emptyCreatedList() { + var snapshot = buildSnapshot( + new ConversationStepData(KEY_CREATED, List.of(), null, null)); + + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + assertTrue(gc.getCreatedAgentIds().isEmpty()); + } + + @Test + @DisplayName("ignores non-String elements in created IDs") + void nonStringElementsIgnored() { + var mixedList = new ArrayList<>(); + mixedList.add("agent-valid"); + mixedList.add(42); + mixedList.add(null); + mixedList.add("agent-also-valid"); + + var snapshot = buildSnapshot( + new ConversationStepData(KEY_CREATED, mixedList, null, null)); + + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + + assertEquals(2, gc.getCreatedAgentIds().size()); + assertTrue(gc.getCreatedAgentIds().contains("agent-valid")); + assertTrue(gc.getCreatedAgentIds().contains("agent-also-valid")); + } + } + + // ========================================================================= + // Retained Agent IDs Propagation + // ========================================================================= + + @Nested + @DisplayName("Retained agent IDs propagation") + class RetainedAgentIds { + + @Test + @DisplayName("propagates retained agent IDs from snapshot to GroupConversation") + void propagatesRetainedIds() { + Set retainedSet = ConcurrentHashMap.newKeySet(); + retainedSet.add("retained-1"); + retainedSet.add("retained-2"); + + var snapshot = buildSnapshot( + new ConversationStepData(KEY_RETAINED, retainedSet, null, null)); + + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + + assertTrue(gc.getRetainedAgentIds().contains("retained-1")); + assertTrue(gc.getRetainedAgentIds().contains("retained-2")); + } + + @Test + @DisplayName("Set type value (ConcurrentHashMap.KeySetView) is handled correctly") + void setTypeValue() { + // This tests that the fix for Bug 2 (storing Set reference directly + // instead of copying to ArrayList) works at the reader side + Set originalSet = ConcurrentHashMap.newKeySet(); + originalSet.add("agent-x"); + + var snapshot = buildSnapshot( + new ConversationStepData(KEY_RETAINED, originalSet, null, null)); + + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + assertTrue(gc.getRetainedAgentIds().contains("agent-x")); + } + + @Test + @DisplayName("ignores non-String elements in retained IDs") + void nonStringRetainedIgnored() { + var mixedList = new ArrayList<>(); + mixedList.add("retained-ok"); + mixedList.add(3.14); + mixedList.add(null); + + var snapshot = buildSnapshot( + new ConversationStepData(KEY_RETAINED, mixedList, null, null)); + + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + assertEquals(1, gc.getRetainedAgentIds().size()); + assertTrue(gc.getRetainedAgentIds().contains("retained-ok")); + } + } + + // ========================================================================= + // Combined / Edge Cases + // ========================================================================= + + @Nested + @DisplayName("Combined and edge cases") + class CombinedCases { + + @Test + @DisplayName("both created and retained IDs in same step") + void bothKeysInSameStep() { + var snapshot = buildSnapshot( + new ConversationStepData(KEY_CREATED, List.of("created-1"), null, null), + new ConversationStepData(KEY_RETAINED, Set.of("retained-1"), null, null)); + + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + + assertEquals(1, gc.getCreatedAgentIds().size()); + assertTrue(gc.getCreatedAgentIds().contains("created-1")); + assertEquals(1, gc.getRetainedAgentIds().size()); + assertTrue(gc.getRetainedAgentIds().contains("retained-1")); + } + + @Test + @DisplayName("non-Collection value for tracking key → ignored") + void nonCollectionValue_ignored() { + var snapshot = buildSnapshot( + new ConversationStepData(KEY_CREATED, "not-a-collection", null, null)); + + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + assertTrue(gc.getCreatedAgentIds().isEmpty()); + } + + @Test + @DisplayName("unrelated keys in step data → ignored") + void unrelatedKeys_ignored() { + var snapshot = buildSnapshot( + new ConversationStepData("output:text", "hello", null, null), + new ConversationStepData("actions:greet", List.of("greet"), null, null), + new ConversationStepData(KEY_CREATED, List.of("agent-1"), null, null)); + + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + + // Only the dynamic:created key should be processed + assertEquals(1, gc.getCreatedAgentIds().size()); + assertTrue(gc.getCreatedAgentIds().contains("agent-1")); + } + + @Test + @DisplayName("reads only the LAST step (not earlier steps)") + void readsOnlyLastStep() { + var snapshot = new SimpleConversationMemorySnapshot(); + + // First step has some created IDs + var step1 = new SimpleConversationStep(); + step1.getConversationStep().add( + new ConversationStepData(KEY_CREATED, List.of("agent-old"), null, null)); + snapshot.getConversationSteps().add(step1); + + // Last step has different created IDs + var step2 = new SimpleConversationStep(); + step2.getConversationStep().add( + new ConversationStepData(KEY_CREATED, List.of("agent-new"), null, null)); + snapshot.getConversationSteps().add(step2); + + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + + // Only the last step's data should be propagated + assertEquals(1, gc.getCreatedAgentIds().size()); + assertTrue(gc.getCreatedAgentIds().contains("agent-new")); + assertFalse(gc.getCreatedAgentIds().contains("agent-old")); + } + + @Test + @DisplayName("multiple turns accumulate IDs (idempotent)") + void multipleTurns_accumulate() { + // Simulate turn 1 + var snapshot1 = buildSnapshot( + new ConversationStepData(KEY_CREATED, List.of("agent-1"), null, null)); + GroupConversationService.propagateDynamicAgentTracking(snapshot1, gc); + + // Simulate turn 2 + var snapshot2 = buildSnapshot( + new ConversationStepData(KEY_CREATED, List.of("agent-2"), null, null)); + GroupConversationService.propagateDynamicAgentTracking(snapshot2, gc); + + assertEquals(2, gc.getCreatedAgentIds().size()); + assertTrue(gc.getCreatedAgentIds().containsAll(List.of("agent-1", "agent-2"))); + } + } + + // ========================================================================= + // Data Key Contract + // ========================================================================= + + @Nested + @DisplayName("Data key contract with AgentOrchestrator") + class DataKeyContract { + + /** + * Verifies that the propagation method recognizes the exact key strings used by + * AgentOrchestrator. If either side changes their key, the corresponding test + * below will fail because propagation won't work. + */ + @Test + @DisplayName("propagation recognizes 'dynamic:created_agent_ids' key") + void createdKeyRecognized() { + // This is the exact key AgentOrchestrator.KEY_DYNAMIC_CREATED_AGENT_IDS stores. + // If the key changes in AgentOrchestrator without updating + // GroupConversationService, + // this test will pass but the null/empty guard tests above will start + // failing (the propagation code won't find the key). + var snapshot = buildSnapshot( + new ConversationStepData("dynamic:created_agent_ids", + List.of("contract-test"), null, null)); + + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + assertTrue(gc.getCreatedAgentIds().contains("contract-test"), + "Propagation must recognize 'dynamic:created_agent_ids' key"); + } + + @Test + @DisplayName("propagation recognizes 'dynamic:retained_agent_ids' key") + void retainedKeyRecognized() { + var snapshot = buildSnapshot( + new ConversationStepData("dynamic:retained_agent_ids", + Set.of("contract-test"), null, null)); + + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + assertTrue(gc.getRetainedAgentIds().contains("contract-test"), + "Propagation must recognize 'dynamic:retained_agent_ids' key"); + } + + @Test + @DisplayName("unrecognized key prefix is ignored (no false positives)") + void wrongKeyPrefix_ignored() { + var snapshot = buildSnapshot( + new ConversationStepData("dynamic:unknown_ids", + List.of("should-not-appear"), null, null)); + + GroupConversationService.propagateDynamicAgentTracking(snapshot, gc); + assertTrue(gc.getCreatedAgentIds().isEmpty()); + assertTrue(gc.getRetainedAgentIds().isEmpty()); + } + } + + // ========================================================================= + // Helper + // ========================================================================= + + /** + * Builds a snapshot with a single step containing the given step data entries. + */ + private SimpleConversationMemorySnapshot buildSnapshot(ConversationStepData... entries) { + var snapshot = new SimpleConversationMemorySnapshot(); + var step = new SimpleConversationStep(); + for (var entry : entries) { + step.getConversationStep().add(entry); + } + snapshot.getConversationSteps().add(step); + return snapshot; + } +} diff --git a/src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceTaskForceTest.java b/src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceTaskForceTest.java new file mode 100644 index 000000000..1b9c0fac3 --- /dev/null +++ b/src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceTaskForceTest.java @@ -0,0 +1,536 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.engine.internal; + +import ai.labs.eddi.configs.agents.AgentSigningService; +import ai.labs.eddi.configs.groups.IAgentGroupStore; +import ai.labs.eddi.configs.groups.IGroupConversationStore; +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.GroupMember; +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.TranscriptEntry; +import ai.labs.eddi.configs.groups.model.GroupConversation.TranscriptEntryType; +import ai.labs.eddi.configs.groups.model.SharedTaskList; +import ai.labs.eddi.configs.groups.model.SharedTaskList.TaskItem; +import ai.labs.eddi.engine.lifecycle.GroupConversationEventSink; +import ai.labs.eddi.engine.api.IGroupConversationService.GroupDiscussionEventListener; +import ai.labs.eddi.configs.groups.model.SharedTaskList.TaskStatus; +import ai.labs.eddi.datastore.serialization.IJsonSerialization; +import ai.labs.eddi.engine.api.IConversationService; +import ai.labs.eddi.engine.api.IGroupConversationService.GroupDiscussionException; +import ai.labs.eddi.engine.tenancy.QuotaExceededException; +import ai.labs.eddi.engine.runtime.IAgentFactory; +import ai.labs.eddi.modules.templating.ITemplatingEngine; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import java.lang.reflect.Method; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.MockitoAnnotations.openMocks; + +/** + * Regression tests for TASK_FORCE code review fixes. Each test targets a + * specific bug fix to prevent regressions: + *
    + *
  • C1: {@code addTask→updateTask} duplicate task bug
  • + *
  • H3: Round-robin with moderator exclusion
  • + *
  • H4: Verification JSON parser
  • + *
  • H6: {@code handleTaskFailure} error propagation
  • + *
  • M1: {@code setMemberConversationIds} ConcurrentHashMap
  • + *
+ * + * @author ginccc + */ +@DisplayName("GroupConversationService — TASK_FORCE Regression Tests") +class GroupConversationServiceTaskForceTest { + + @Mock + private IAgentGroupStore groupStore; + @Mock + private IGroupConversationStore conversationStore; + @Mock + private IConversationService conversationService; + @Mock + private IAgentFactory agentFactory; + @Mock + private ITemplatingEngine templatingEngine; + @Mock + private IJsonSerialization jsonSerialization; + + private GroupConversationService service; + + private static final List MEMBERS = List.of( + new GroupMember("agent-1", "Analyst", 0, "RESEARCHER"), + new GroupMember("agent-2", "Writer", 1, "AUTHOR"), + new GroupMember("mod-agent", "Moderator", 2, "MODERATOR")); + + @BeforeEach + void setUp() { + openMocks(this); + service = new GroupConversationService( + groupStore, conversationStore, conversationService, + agentFactory, templatingEngine, jsonSerialization, + new SimpleMeterRegistry(), null, null, null, "default", 3); + } + + // ================================================================= + // H3: resolveTaskAssignment — moderator exclusion + round-robin + // ================================================================= + + @Nested + @DisplayName("resolveTaskAssignment (H3 fix)") + class ResolveTaskAssignmentTests { + + private Method resolveMethod; + + @BeforeEach + void setUp() throws Exception { + resolveMethod = GroupConversationService.class.getDeclaredMethod( + "resolveTaskAssignment", String.class, List.class, String.class, int.class); + resolveMethod.setAccessible(true); + } + + private String invoke(String assignToRole, List members, + String moderatorAgentId, int taskIndex) + throws Exception { + return (String) resolveMethod.invoke(service, assignToRole, members, moderatorAgentId, taskIndex); + } + + @Test + @DisplayName("ALL role round-robins across non-moderator members") + void allRole_excludesModerator_roundRobins() throws Exception { + // With moderator "mod-agent", only agent-1 and agent-2 are eligible + String first = invoke("ALL", MEMBERS, "mod-agent", 0); + String second = invoke("ALL", MEMBERS, "mod-agent", 1); + String third = invoke("ALL", MEMBERS, "mod-agent", 2); + + assertEquals("agent-1", first); + assertEquals("agent-2", second); + assertEquals("agent-1", third, "Should wrap around to agent-1"); + } + + @Test + @DisplayName("null role behaves like ALL") + void nullRole_behavesLikeAll() throws Exception { + String result = invoke(null, MEMBERS, "mod-agent", 0); + assertEquals("agent-1", result); + } + + @Test + @DisplayName("ALL role with null moderator includes everyone") + void allRole_nullModerator_includesAll() throws Exception { + // With null moderator, equals(null) returns false for all → all eligible + String first = invoke("ALL", MEMBERS, null, 0); + String second = invoke("ALL", MEMBERS, null, 1); + String third = invoke("ALL", MEMBERS, null, 2); + + assertEquals("agent-1", first); + assertEquals("agent-2", second); + assertEquals("mod-agent", third); + } + + @Test + @DisplayName("ROLE:RESEARCHER resolves to matching member") + void rolePrefix_matchesByRole() throws Exception { + String result = invoke("ROLE:RESEARCHER", MEMBERS, "mod-agent", 0); + assertEquals("agent-1", result); + } + + @Test + @DisplayName("ROLE:NONEXISTENT returns null") + void rolePrefix_noMatch_returnsNull() throws Exception { + String result = invoke("ROLE:NONEXISTENT", MEMBERS, "mod-agent", 0); + assertNull(result); + } + + @Test + @DisplayName("Direct agentId reference resolves") + void directAgentId_resolves() throws Exception { + String result = invoke("agent-2", MEMBERS, "mod-agent", 0); + assertEquals("agent-2", result); + } + + @Test + @DisplayName("Empty members returns null") + void emptyMembers_returnsNull() throws Exception { + String result = invoke("ALL", List.of(), "mod-agent", 0); + assertNull(result); + } + } + + // ================================================================= + // H4: tryParseVerificationJson — dedicated JSON parser + // ================================================================= + + @Nested + @DisplayName("tryParseVerificationJson (H4 fix)") + class VerificationJsonParserTests { + + private Method parseMethod; + + @BeforeEach + void setUp() throws Exception { + parseMethod = GroupConversationService.class.getDeclaredMethod( + "tryParseVerificationJson", GroupConversation.class, List.class, + String.class, GroupDiscussionEventListener.class); + parseMethod.setAccessible(true); + } + + private boolean invoke(GroupConversation gc, List completedTasks, + String content) + throws Exception { + return (boolean) parseMethod.invoke(service, gc, completedTasks, content, null); + } + + private GroupConversation createGcWithTaskList() { + var gc = new GroupConversation(); + gc.setTaskList(new SharedTaskList()); + gc.setTranscript(new ArrayList<>()); + return gc; + } + + @Test + @DisplayName("Valid JSON with passed=true verifies task") + void validJson_passedTrue_verifiesTask() throws Exception { + var gc = createGcWithTaskList(); + var task = gc.getTaskList().addTask(new TaskItem("Write report", "desc", 0)); + gc.getTaskList().assignTask(task.id(), "agent-1", "A1"); + gc.getTaskList().startTask(task.id()); + gc.getTaskList().completeTask(task.id(), "done"); + + List completed = List.of(gc.getTaskList().findById(task.id())); + String json = "[{\"subject\": \"Write report\", \"passed\": true, \"feedback\": \"Looks good\"}]"; + when(jsonSerialization.deserialize(anyString(), eq(List.class))) + .thenReturn(List.of(Map.of("subject", "Write report", "passed", true, "feedback", "Looks good"))); + + boolean result = invoke(gc, completed, json); + + assertTrue(result, "Should return true for successful verification"); + assertEquals(TaskStatus.VERIFIED, gc.getTaskList().findById(task.id()).status()); + } + + @Test + @DisplayName("Valid JSON with passed=false marks verification with note") + void validJson_passedFalse_verifiesWithFail() throws Exception { + var gc = createGcWithTaskList(); + var task = gc.getTaskList().addTask(new TaskItem("Write report", "desc", 0)); + gc.getTaskList().assignTask(task.id(), "agent-1", "A1"); + gc.getTaskList().startTask(task.id()); + gc.getTaskList().completeTask(task.id(), "done"); + + List completed = List.of(gc.getTaskList().findById(task.id())); + String json = "[{\"subject\": \"Write report\", \"passed\": false, \"feedback\": \"Needs work\"}]"; + when(jsonSerialization.deserialize(anyString(), eq(List.class))) + .thenReturn(List.of(Map.of("subject", "Write report", "passed", false, "feedback", "Needs work"))); + + boolean result = invoke(gc, completed, json); + + assertTrue(result, "Should return true even for failed verification"); + assertEquals(TaskStatus.FAILED, gc.getTaskList().findById(task.id()).status()); + assertFalse(gc.getTaskList().findById(task.id()).verified()); + } + + @Test + @DisplayName("Content without JSON brackets returns false") + void noJsonBrackets_returnsFalse() throws Exception { + var gc = createGcWithTaskList(); + boolean result = invoke(gc, List.of(), "Just some plain text without brackets"); + + assertFalse(result); + } + + @Test + @DisplayName("Empty JSON array returns false") + void emptyJsonArray_returnsFalse() throws Exception { + var gc = createGcWithTaskList(); + when(jsonSerialization.deserialize(anyString(), eq(List.class))) + .thenReturn(List.of()); + + boolean result = invoke(gc, List.of(), "[]"); + + assertFalse(result); + } + + @Test + @DisplayName("Subject matching is case-insensitive") + void subjectMatching_caseInsensitive() throws Exception { + var gc = createGcWithTaskList(); + var task = gc.getTaskList().addTask(new TaskItem("Write Report", "desc", 0)); + gc.getTaskList().assignTask(task.id(), "agent-1", "A1"); + gc.getTaskList().startTask(task.id()); + gc.getTaskList().completeTask(task.id(), "done"); + + List completed = List.of(gc.getTaskList().findById(task.id())); + when(jsonSerialization.deserialize(anyString(), eq(List.class))) + .thenReturn(List.of(Map.of("subject", "write report", "passed", true))); + + boolean result = invoke(gc, completed, "[{\"subject\":\"write report\",\"passed\":true}]"); + + assertTrue(result, "Case-insensitive subject match should work"); + assertEquals(TaskStatus.VERIFIED, gc.getTaskList().findById(task.id()).status()); + } + + @Test + @DisplayName("Deserialization exception returns false gracefully") + void deserializationError_returnsFalse() throws Exception { + var gc = createGcWithTaskList(); + when(jsonSerialization.deserialize(anyString(), eq(List.class))) + .thenThrow(new RuntimeException("Bad JSON")); + + boolean result = invoke(gc, List.of(), "[broken json]"); + + assertFalse(result, "Should return false on deserialization failure"); + } + } + + // ================================================================= + // H6: handleTaskFailure — error propagation + // ================================================================= + + @Nested + @DisplayName("handleTaskFailure (H6 fix)") + class HandleTaskFailureTests { + + private Method handleMethod; + + @BeforeEach + void setUp() throws Exception { + handleMethod = GroupConversationService.class.getDeclaredMethod( + "handleTaskFailure", + GroupConversation.class, TaskItem.class, GroupMember.class, + String.class, int.class, DiscussionPhase.class, + GroupDiscussionEventListener.class, + List.class, GroupDiscussionException.class); + handleMethod.setAccessible(true); + } + + @Test + @DisplayName("Marks task as FAILED, adds transcript entry, collects error") + void basicFailure_marksAndRecords() throws Exception { + var gc = new GroupConversation(); + gc.setTaskList(new SharedTaskList()); + gc.setTranscript(Collections.synchronizedList(new ArrayList<>())); + + var task = gc.getTaskList().addTask(new TaskItem("Task A", "desc", 0)); + gc.getTaskList().assignTask(task.id(), "agent-1", "Agent One"); + gc.getTaskList().startTask(task.id()); + + // Refresh task reference after state change + task = gc.getTaskList().findById(task.id()); + + var member = new GroupMember("agent-1", "Agent One", 0, "MEMBER"); + var phase = new DiscussionPhase("Execute", PhaseType.EXECUTE, "ALL", + AgentGroupConfiguration.TurnOrder.PARALLEL, AgentGroupConfiguration.ContextScope.TASK_ONLY, + false, null, 1); + var errors = new ArrayList(); + var ex = new GroupDiscussionException("LLM timeout"); + + handleMethod.invoke(service, gc, task, member, "LLM timeout", 1, phase, null, errors, ex); + + // Task should be FAILED + assertEquals(TaskStatus.FAILED, gc.getTaskList().findById(task.id()).status()); + + // Transcript should have an error entry + assertEquals(1, gc.getTranscript().size()); + assertTrue(gc.getTranscript().getFirst().content().contains("[ERROR]")); + assertEquals(TranscriptEntryType.TASK_RESULT, gc.getTranscript().getFirst().type()); + + // Error should be collected + assertEquals(1, errors.size()); + assertSame(ex, errors.getFirst()); + } + + @Test + @DisplayName("Already terminal task is handled gracefully (no crash)") + void alreadyTerminalTask_doesNotCrash() throws Exception { + var gc = new GroupConversation(); + gc.setTaskList(new SharedTaskList()); + gc.setTranscript(Collections.synchronizedList(new ArrayList<>())); + + var task = gc.getTaskList().addTask(new TaskItem("Task A", "desc", 0)); + gc.getTaskList().failTask(task.id(), "already failed"); + + var failedTask = gc.getTaskList().findById(task.id()); + + var member = new GroupMember("agent-1", "Agent One", 0, "MEMBER"); + var phase = new DiscussionPhase("Execute", PhaseType.EXECUTE, "ALL", + AgentGroupConfiguration.TurnOrder.PARALLEL, AgentGroupConfiguration.ContextScope.TASK_ONLY, + false, null, 1); + var errors = new ArrayList(); + var ex = new GroupDiscussionException("Second failure"); + + // Should NOT throw even though task is already FAILED + assertDoesNotThrow(() -> handleMethod.invoke(service, gc, failedTask, member, "Second failure", 1, phase, null, errors, ex)); + + // Error still collected + assertEquals(1, errors.size()); + // Transcript entry still added + assertEquals(1, gc.getTranscript().size()); + } + } + + // ================================================================= + // M1: setMemberConversationIds — ConcurrentHashMap defensive wrap + // ================================================================= + + @Nested + @DisplayName("setMemberConversationIds (M1 fix)") + class MemberConversationIdsTests { + + @Test + @DisplayName("Setter wraps plain HashMap into ConcurrentHashMap") + void setter_wrapsInConcurrentHashMap() { + var gc = new GroupConversation(); + gc.setMemberConversationIds(new java.util.LinkedHashMap<>( + Map.of("agent-1", "conv-1"))); + + assertTrue(gc.getMemberConversationIds() instanceof java.util.concurrent.ConcurrentHashMap, + "Must be ConcurrentHashMap after setter"); + assertEquals("conv-1", gc.getMemberConversationIds().get("agent-1")); + } + + @Test + @DisplayName("Setter with null creates empty ConcurrentHashMap") + void setter_nullCreatesEmptyConcurrentHashMap() { + var gc = new GroupConversation(); + gc.setMemberConversationIds(null); + + assertNotNull(gc.getMemberConversationIds()); + assertTrue(gc.getMemberConversationIds() instanceof java.util.concurrent.ConcurrentHashMap); + assertTrue(gc.getMemberConversationIds().isEmpty()); + } + } + + // ================================================================= + // Quota enforcement — abort on QuotaExceededException + // ================================================================= + + @Nested + @DisplayName("Quota enforcement in executeAgentTurn") + class QuotaEnforcementTests { + + @Test + @DisplayName("QuotaExceededException from startConversation wraps as GroupDiscussionException") + void startConversation_quotaExceeded_throwsGroupDiscussionException() throws Exception { + var method = GroupConversationService.class.getDeclaredMethod( + "executeAgentTurn", GroupMember.class, GroupConversation.class, + String.class, AgentGroupConfiguration.ProtocolConfig.class, + int.class, DiscussionPhase.class, String.class); + method.setAccessible(true); + + var member = new GroupMember("agent-1", "Agent One", 0, "MEMBER"); + var gc = new GroupConversation(); + gc.setTranscript(new ArrayList<>()); + gc.setMemberConversationIds(new java.util.concurrent.ConcurrentHashMap<>()); + + var protocol = new AgentGroupConfiguration.ProtocolConfig( + 60, AgentGroupConfiguration.ProtocolConfig.MemberFailurePolicy.SKIP, 2, + AgentGroupConfiguration.ProtocolConfig.MemberUnavailablePolicy.SKIP); + var phase = new DiscussionPhase("Execute", PhaseType.EXECUTE, "ALL", + AgentGroupConfiguration.TurnOrder.PARALLEL, AgentGroupConfiguration.ContextScope.TASK_ONLY, + false, null, 1); + + // Agent is available but startConversation throws quota error + when(agentFactory.getLatestReadyAgent(any(), eq("agent-1"))).thenReturn(mock(ai.labs.eddi.engine.runtime.IAgent.class)); + when(conversationService.startConversation(any(), eq("agent-1"), any(), any())) + .thenThrow(new QuotaExceededException("Conversation limit reached")); + + var ex = assertThrows(java.lang.reflect.InvocationTargetException.class, + () -> method.invoke(service, member, gc, "test input", protocol, 0, phase, null)); + + assertInstanceOf(GroupDiscussionException.class, ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("Tenant quota exceeded")); + assertInstanceOf(QuotaExceededException.class, ex.getCause().getCause()); + } + + @Test + @DisplayName("QuotaExceededException from say() wraps as GroupDiscussionException") + void say_quotaExceeded_throwsGroupDiscussionException() throws Exception { + var method = GroupConversationService.class.getDeclaredMethod( + "executeAgentTurn", GroupMember.class, GroupConversation.class, + String.class, AgentGroupConfiguration.ProtocolConfig.class, + int.class, DiscussionPhase.class, String.class); + method.setAccessible(true); + + var member = new GroupMember("agent-1", "Agent One", 0, "MEMBER"); + var gc = new GroupConversation(); + gc.setTranscript(new ArrayList<>()); + // Pre-set a conversation ID so startConversation is skipped + gc.setMemberConversationIds(new java.util.concurrent.ConcurrentHashMap<>( + Map.of("agent-1", "existing-conv"))); + + var protocol = new AgentGroupConfiguration.ProtocolConfig( + 60, AgentGroupConfiguration.ProtocolConfig.MemberFailurePolicy.SKIP, 2, + AgentGroupConfiguration.ProtocolConfig.MemberUnavailablePolicy.SKIP); + var phase = new DiscussionPhase("Execute", PhaseType.EXECUTE, "ALL", + AgentGroupConfiguration.TurnOrder.PARALLEL, AgentGroupConfiguration.ContextScope.TASK_ONLY, + false, null, 1); + + // Agent is available + when(agentFactory.getLatestReadyAgent(any(), eq("agent-1"))).thenReturn(mock(ai.labs.eddi.engine.runtime.IAgent.class)); + // say() throws quota error + doThrow(new QuotaExceededException("API call limit reached")) + .when(conversationService).say(any(), eq("agent-1"), eq("existing-conv"), + any(), any(), any(), any(), anyBoolean(), any()); + + var ex = assertThrows(java.lang.reflect.InvocationTargetException.class, + () -> method.invoke(service, member, gc, "test input", protocol, 0, phase, null)); + + assertInstanceOf(GroupDiscussionException.class, ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("Tenant quota exceeded")); + assertInstanceOf(QuotaExceededException.class, ex.getCause().getCause()); + } + + @Test + @DisplayName("QuotaExceededException is NOT retried even with RETRY policy") + void quota_notRetried_evenWithRetryPolicy() throws Exception { + var method = GroupConversationService.class.getDeclaredMethod( + "executeAgentTurn", GroupMember.class, GroupConversation.class, + String.class, AgentGroupConfiguration.ProtocolConfig.class, + int.class, DiscussionPhase.class, String.class); + method.setAccessible(true); + + var member = new GroupMember("agent-1", "Agent One", 0, "MEMBER"); + var gc = new GroupConversation(); + gc.setTranscript(new ArrayList<>()); + gc.setMemberConversationIds(new java.util.concurrent.ConcurrentHashMap<>( + Map.of("agent-1", "existing-conv"))); + + // RETRY policy with 5 retries — quota should still abort immediately + var protocol = new AgentGroupConfiguration.ProtocolConfig( + 60, AgentGroupConfiguration.ProtocolConfig.MemberFailurePolicy.RETRY, 5, + AgentGroupConfiguration.ProtocolConfig.MemberUnavailablePolicy.SKIP); + var phase = new DiscussionPhase("Execute", PhaseType.EXECUTE, "ALL", + AgentGroupConfiguration.TurnOrder.PARALLEL, AgentGroupConfiguration.ContextScope.TASK_ONLY, + false, null, 1); + + when(agentFactory.getLatestReadyAgent(any(), eq("agent-1"))).thenReturn(mock(ai.labs.eddi.engine.runtime.IAgent.class)); + doThrow(new QuotaExceededException("API call limit reached")) + .when(conversationService).say(any(), eq("agent-1"), eq("existing-conv"), + any(), any(), any(), any(), anyBoolean(), any()); + + var ex = assertThrows(java.lang.reflect.InvocationTargetException.class, + () -> method.invoke(service, member, gc, "test input", protocol, 0, phase, null)); + + assertInstanceOf(GroupDiscussionException.class, ex.getCause()); + // Verify say() was called exactly once (no retries) + verify(conversationService, times(1)).say(any(), eq("agent-1"), eq("existing-conv"), + any(), any(), any(), any(), anyBoolean(), any()); + } + } +} diff --git a/src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceTest.java b/src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceTest.java index 87516d28a..43713f473 100644 --- a/src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceTest.java +++ b/src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceTest.java @@ -599,7 +599,7 @@ void flatOutputTextKeys_withMapValue() throws Exception { } @Test - void noOutputKeys_returnsNull() throws Exception { + void noOutputKeys_returnsEmptyString() throws Exception { var snapshot = new SimpleConversationMemorySnapshot(); var output = new ConversationOutput(); // Only pipeline metadata keys — no "output" or "reply" keys @@ -607,11 +607,13 @@ void noOutputKeys_returnsNull() throws Exception { output.put("input", "some input"); snapshot.setConversationOutputs(new LinkedList<>(List.of(output))); - assertNull(invoke(snapshot)); + // ConversationOutputExtractor returns null for metadata-only; + // GCS wrapper converts null → "" for backward compat + assertEquals("", invoke(snapshot)); } @Test - void hasOutputKeyButNoTexts_fallsBackToSerialization() throws Exception { + void hasOutputKeyButNoTexts_fallsBackToToString() throws Exception { var snapshot = new SimpleConversationMemorySnapshot(); var output = new ConversationOutput(); // Has an "output" key prefix but doesn't yield any text via known formats @@ -620,10 +622,13 @@ void hasOutputKeyButNoTexts_fallsBackToSerialization() throws Exception { output.put("output-status", "done"); snapshot.setConversationOutputs(new LinkedList<>(List.of(output))); - doReturn("{\"output-status\":\"done\"}").when(jsonSerialization).serialize(output); - + // ConversationOutputExtractor uses toString() fallback (no + // jsonSerialization dependency). Just verify non-null and + // contains output key content. String result = invoke(snapshot); - assertEquals("{\"output-status\":\"done\"}", result); + assertNotNull(result, "Should fall back to toString()"); + assertTrue(result.contains("output-status"), "Fallback should include output key"); + assertTrue(result.contains("done"), "Fallback should include value"); } @Test diff --git a/src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceUncoveredBranchTest.java b/src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceUncoveredBranchTest.java index 970738561..a7c77bde2 100644 --- a/src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceUncoveredBranchTest.java +++ b/src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceUncoveredBranchTest.java @@ -318,13 +318,15 @@ void multipleOutputKeys() throws Exception { class ExtractResponseMetadata { @Test - @DisplayName("no output or reply keys returns null") + @DisplayName("no output or reply keys returns empty string") void metadataOnly() throws Exception { var output = new ConversationOutput(); output.put("actions", List.of("greet")); output.put("input", "hello"); var snapshot = createSnapshot(output); - assertNull(invokeExtractResponse(snapshot)); + // ConversationOutputExtractor returns null for metadata-only; + // GCS wrapper converts null → "" for backward compat + assertEquals("", invokeExtractResponse(snapshot)); } @Test diff --git a/src/test/java/ai/labs/eddi/engine/internal/TaskListParserTest.java b/src/test/java/ai/labs/eddi/engine/internal/TaskListParserTest.java new file mode 100644 index 000000000..fca52578c --- /dev/null +++ b/src/test/java/ai/labs/eddi/engine/internal/TaskListParserTest.java @@ -0,0 +1,512 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.engine.internal; + +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.GroupMember; +import ai.labs.eddi.engine.internal.TaskListParser.ParsedTask; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link TaskListParser}. + * + * @author ginccc + */ +class TaskListParserTest { + + private static final List MEMBERS = List.of( + new GroupMember("agent-1", "Analyst", 0, "RESEARCHER"), + new GroupMember("agent-2", "Writer", 1, "AUTHOR")); + + // --- Tier 1: JSON parsing --- + + @Test + @DisplayName("Tier 1 — JSON array input parses into structured tasks") + void parseValidJson_array() { + String json = """ + [{"subject":"Task A","description":"Do A","assignedTo":"agent-1","priority":0}] + """; + + List tasks = TaskListParser.parse(json, MEMBERS); + + assertEquals(1, tasks.size()); + ParsedTask task = tasks.getFirst(); + assertEquals("Task A", task.subject()); + assertEquals("Do A", task.description()); + assertEquals("agent-1", task.assignedTo()); + assertEquals(0, task.priority()); + } + + @Test + @DisplayName("Tier 1 — unknown JSON fields are silently ignored") + void parseValidJson_withExtraFields() { + String json = """ + [{"subject":"A","description":"B","foo":"bar","baz":42}] + """; + + List tasks = TaskListParser.parse(json, MEMBERS); + + assertEquals(1, tasks.size()); + assertEquals("A", tasks.getFirst().subject()); + assertEquals("B", tasks.getFirst().description()); + } + + @Test + @DisplayName("Tier 1 — JSON object wrapping an array property is unwrapped") + void parseJsonObject_wrappedInProperty() { + String json = """ + {"tasks":[{"subject":"A","description":"B"}]} + """; + + List tasks = TaskListParser.parse(json, MEMBERS); + + assertEquals(1, tasks.size()); + assertEquals("A", tasks.getFirst().subject()); + assertEquals("B", tasks.getFirst().description()); + } + + // --- Tier 2: Markdown parsing --- + + @Test + @DisplayName("Tier 2 — malformed JSON falls through to markdown parsing") + void parseMalformedJson_fallsToMarkdown() { + String markdown = "1. Task A: description A\n2. Task B: description B"; + + List tasks = TaskListParser.parse(markdown, MEMBERS); + + assertEquals(2, tasks.size()); + assertEquals("Task A", tasks.get(0).subject()); + assertEquals("description A", tasks.get(0).description()); + assertEquals("Task B", tasks.get(1).subject()); + assertEquals("description B", tasks.get(1).description()); + } + + @Test + @DisplayName("Tier 2 — numbered list items are parsed") + void parseMarkdown_numberedList() { + String markdown = "1. Research competitors\n2. Draft strategy\n3. Review plan"; + + List tasks = TaskListParser.parse(markdown, MEMBERS); + + assertEquals(3, tasks.size()); + assertEquals("Research competitors", tasks.get(0).subject()); + assertEquals("Draft strategy", tasks.get(1).subject()); + assertEquals("Review plan", tasks.get(2).subject()); + } + + @Test + @DisplayName("Tier 2 — bulleted list items are parsed") + void parseMarkdown_bulletedList() { + String markdown = "* Analyze data\n* Draft report"; + + List tasks = TaskListParser.parse(markdown, MEMBERS); + + assertEquals(2, tasks.size()); + assertEquals("Analyze data", tasks.get(0).subject()); + assertEquals("Draft report", tasks.get(1).subject()); + } + + @Test + @DisplayName("Tier 2 — inline (assigned to ...) annotations are extracted") + void parseMarkdown_withAssignments() { + String markdown = "1. Research (assigned to Analyst)\n2. Draft (assigned to Writer)"; + + List tasks = TaskListParser.parse(markdown, MEMBERS); + + assertEquals(2, tasks.size()); + assertEquals("Research", tasks.get(0).subject()); + assertEquals("Analyst", tasks.get(0).assignedTo()); + assertEquals("Draft", tasks.get(1).subject()); + assertEquals("Writer", tasks.get(1).assignedTo()); + } + + // --- Tier 3: Fallback --- + + @Test + @DisplayName("Tier 3 — unrecognizable text falls back to a single task") + void parseGarbage_fallsToSingleTask() { + String garbage = "This is not a task list at all"; + + List tasks = TaskListParser.parse(garbage, MEMBERS); + + assertEquals(1, tasks.size()); + assertNotNull(tasks.getFirst().subject()); + } + + @Test + @DisplayName("Tier 3 — null input falls back to a single task") + void parseEmpty_fallsToSingleTask() { + List tasks = TaskListParser.parse(null, MEMBERS); + + assertEquals(1, tasks.size()); + assertNotNull(tasks.getFirst().subject()); + } + + // --- Agent resolution --- + + @Test + @DisplayName("resolveAgent matches by exact agentId") + void resolveAgent_byId() { + String resolved = TaskListParser.resolveAgent("agent-1", MEMBERS); + + assertEquals("agent-1", resolved); + } + + @Test + @DisplayName("resolveAgent matches by displayName (case-insensitive)") + void resolveAgent_byDisplayName() { + String resolved = TaskListParser.resolveAgent("analyst", MEMBERS); + + assertEquals("agent-1", resolved); + } + + @Test + @DisplayName("resolveAgent returns null for unknown references") + void resolveAgent_unknown_returnsNull() { + String resolved = TaskListParser.resolveAgent("unknown-agent", MEMBERS); + + assertNull(resolved); + } + + // --- Edge cases (code review gap coverage) --- + + @Test + @DisplayName("Empty JSON array falls back to single task") + void emptyJsonArray_fallsToSingleTask() { + var result = TaskListParser.parse("[]", MEMBERS); + + assertEquals(1, result.size()); + assertEquals("Complete goal", result.getFirst().subject()); + } + + @Test + @DisplayName("JSON wrapped in markdown code fences is parsed") + void jsonInCodeFences() { + String input = """ + Here are the tasks: + ```json + [{"subject": "Task A", "description": "Do A"}] + ``` + """; + var result = TaskListParser.parse(input, MEMBERS); + + assertEquals(1, result.size()); + assertEquals("Task A", result.getFirst().subject()); + } + + @Test + @DisplayName("Empty string input falls back to single task") + void emptyString_fallsToSingleTask() { + var result = TaskListParser.parse("", MEMBERS); + + assertEquals(1, result.size()); + assertEquals("Complete goal", result.getFirst().subject()); + } + + @Test + @DisplayName("JSON with missing required fields is parsed with null subject") + void jsonMissingSubject() { + String input = "[{\"foo\": \"bar\", \"description\": \"some desc\"}]"; + var result = TaskListParser.parse(input, MEMBERS); + + // Should parse but subject will be null from JSON + assertNotNull(result); + assertFalse(result.isEmpty()); + } + + @Test + @DisplayName("roundRobinAssign with empty members returns null") + void roundRobinAssign_emptyMembers() { + String result = TaskListParser.roundRobinAssign(0, List.of()); + assertNull(result); + } + + @Test + @DisplayName("roundRobinAssign distributes evenly") + void roundRobinAssign_distributes() { + assertEquals("agent-1", TaskListParser.roundRobinAssign(0, MEMBERS)); + assertEquals("agent-2", TaskListParser.roundRobinAssign(1, MEMBERS)); + assertEquals("agent-1", TaskListParser.roundRobinAssign(2, MEMBERS)); + } + + @Test + @DisplayName("Tier 3 fallback preserves LLM output as description") + void tier3Fallback_preservesOutput() { + String freeformText = "Just talk to the user about their preferences and find a suitable hotel."; + var result = TaskListParser.parse(freeformText, MEMBERS); + + assertEquals(1, result.size()); + assertEquals("Complete goal", result.getFirst().subject()); + assertTrue(result.getFirst().description().contains("hotel"), + "Fallback should preserve LLM output as task description"); + } + + // --- Additional branch coverage tests --- + + @Test + @DisplayName("JSON object without array field returns null (falls to markdown/fallback)") + void jsonObject_noArrayField() { + String input = "{\"message\": \"hello\", \"count\": 42}"; + var result = TaskListParser.parse(input, MEMBERS); + + // No array field found, no markdown items → falls to single task fallback + assertEquals(1, result.size()); + assertEquals("Complete goal", result.getFirst().subject()); + } + + @Test + @DisplayName("JSON with 'title' alias for subject field") + void jsonAlternativeKey_title() { + String json = """ + [{"title":"Task via Title","description":"Using title key"}] + """; + var result = TaskListParser.parse(json, MEMBERS); + + assertEquals(1, result.size()); + assertEquals("Task via Title", result.getFirst().subject()); + } + + @Test + @DisplayName("JSON with 'name' alias for subject field") + void jsonAlternativeKey_name() { + String json = """ + [{"name":"Task via Name","desc":"Using desc key"}] + """; + var result = TaskListParser.parse(json, MEMBERS); + + assertEquals(1, result.size()); + assertEquals("Task via Name", result.getFirst().subject()); + assertEquals("Using desc key", result.getFirst().description()); + } + + @Test + @DisplayName("JSON with 'assignee' alias for assignedTo") + void jsonAlternativeKey_assignee() { + String json = """ + [{"subject":"Task","description":"D","assignee":"agent-2"}] + """; + var result = TaskListParser.parse(json, MEMBERS); + + assertEquals(1, result.size()); + assertEquals("agent-2", result.getFirst().assignedTo()); + } + + @Test + @DisplayName("JSON with 'assigned_to' alias for assignedTo") + void jsonAlternativeKey_assigned_to() { + String json = """ + [{"subject":"Task","description":"D","assigned_to":"agent-1"}] + """; + var result = TaskListParser.parse(json, MEMBERS); + + assertEquals(1, result.size()); + assertEquals("agent-1", result.getFirst().assignedTo()); + } + + @Test + @DisplayName("JSON with missing description uses subject as description") + void jsonMissingDescription_usesSubject() { + String json = """ + [{"subject":"Only Subject"}] + """; + var result = TaskListParser.parse(json, MEMBERS); + + assertEquals(1, result.size()); + assertEquals("Only Subject", result.getFirst().subject()); + assertEquals("Only Subject", result.getFirst().description()); + } + + @Test + @DisplayName("JSON with priority as number") + void jsonPriority_number() { + String json = """ + [{"subject":"Task","description":"D","priority":3}] + """; + var result = TaskListParser.parse(json, MEMBERS); + + assertEquals(1, result.size()); + assertEquals(3, result.getFirst().priority()); + } + + @Test + @DisplayName("JSON with priority as string (non-number) uses default 0") + void jsonPriority_stringFallback() { + String json = """ + [{"subject":"Task","description":"D","priority":"high"}] + """; + var result = TaskListParser.parse(json, MEMBERS); + + assertEquals(1, result.size()); + assertEquals(0, result.getFirst().priority()); + } + + @Test + @DisplayName("Markdown with dash-separated title and description") + void markdownDashSeparator() { + String markdown = "- Plan — create a detailed plan"; + var result = TaskListParser.parse(markdown, MEMBERS); + + assertEquals(1, result.size()); + assertEquals("Plan", result.getFirst().subject()); + assertEquals("create a detailed plan", result.getFirst().description()); + } + + @Test + @DisplayName("Markdown item without description uses subject as description") + void markdownItemWithoutDescription() { + String markdown = "1. Research competitors"; + var result = TaskListParser.parse(markdown, MEMBERS); + + assertEquals(1, result.size()); + assertEquals("Research competitors", result.getFirst().subject()); + // When no description part, description equals subject + assertEquals("Research competitors", result.getFirst().description()); + } + + @Test + @DisplayName("Markdown with assignedTo= format extracts assignment") + void markdownAssignment_equalsFormat() { + String markdown = "- Research (assignedTo=agent-1) — find references"; + var result = TaskListParser.parse(markdown, MEMBERS); + + assertEquals(1, result.size()); + assertEquals("agent-1", result.getFirst().assignedTo()); + } + + @Test + @DisplayName("resolveAgent with null assignedTo returns null") + void resolveAgent_nullAssignedTo() { + assertNull(TaskListParser.resolveAgent(null, MEMBERS)); + } + + @Test + @DisplayName("resolveAgent with blank assignedTo returns null") + void resolveAgent_blankAssignedTo() { + assertNull(TaskListParser.resolveAgent(" ", MEMBERS)); + } + + @Test + @DisplayName("resolveAgent with null members returns null") + void resolveAgent_nullMembers() { + assertNull(TaskListParser.resolveAgent("agent-1", null)); + } + + @Test + @DisplayName("resolveAgent with empty members returns null") + void resolveAgent_emptyMembers() { + assertNull(TaskListParser.resolveAgent("agent-1", List.of())); + } + + @Test + @DisplayName("resolveAgent matches display name with extra whitespace") + void resolveAgent_displayNameWithWhitespace() { + assertEquals("agent-1", TaskListParser.resolveAgent(" Analyst ", MEMBERS)); + } + + @Test + @DisplayName("roundRobinAssign with null members returns null") + void roundRobinAssign_nullMembers() { + assertNull(TaskListParser.roundRobinAssign(0, null)); + } + + @Test + @DisplayName("Tier 3 fallback truncates very long text to 2000 chars") + void tier3Fallback_truncatesLongText() { + String longText = "A".repeat(5000); + var result = TaskListParser.parse(longText, MEMBERS); + + assertEquals(1, result.size()); + assertEquals(2000, result.getFirst().description().length()); + } + + @Test + @DisplayName("JSON with 'items' wrapper object is unwrapped") + void jsonObjectWithItemsKey() { + String json = """ + {"items":[{"subject":"Task A","description":"Do A"}]} + """; + var result = TaskListParser.parse(json, MEMBERS); + + assertEquals(1, result.size()); + assertEquals("Task A", result.getFirst().subject()); + } + + @Test + @DisplayName("Markdown with plus sign bullet is parsed") + void markdownPlusBullet() { + String markdown = "+ Review code"; + var result = TaskListParser.parse(markdown, MEMBERS); + + assertEquals(1, result.size()); + assertEquals("Review code", result.getFirst().subject()); + } + + @Test + @DisplayName("JSON entries with blank subject are skipped") + void jsonBlankSubject_skipped() { + String json = """ + [{"subject":"","description":"should be skipped"},{"subject":"Valid","description":"keep"}] + """; + var result = TaskListParser.parse(json, MEMBERS); + + assertEquals(1, result.size()); + assertEquals("Valid", result.getFirst().subject()); + } + + @Test + @DisplayName("JSON with 'details' alias for description") + void jsonAlternativeKey_details() { + String json = """ + [{"subject":"Task","details":"Using details key"}] + """; + var result = TaskListParser.parse(json, MEMBERS); + + assertEquals(1, result.size()); + assertEquals("Using details key", result.getFirst().description()); + } + + @Test + @DisplayName("JSON with 'instructions' alias for description") + void jsonAlternativeKey_instructions() { + String json = """ + [{"subject":"Task","instructions":"Using instructions key"}] + """; + var result = TaskListParser.parse(json, MEMBERS); + + assertEquals(1, result.size()); + assertEquals("Using instructions key", result.getFirst().description()); + } + + @Test + @DisplayName("JSON with 'agent' alias for assignedTo") + void jsonAlternativeKey_agent() { + String json = """ + [{"subject":"Task","description":"D","agent":"agent-1"}] + """; + var result = TaskListParser.parse(json, MEMBERS); + + assertEquals(1, result.size()); + assertEquals("agent-1", result.getFirst().assignedTo()); + } + + @Test + @DisplayName("resolveAgent with member having null displayName does not crash") + void resolveAgent_memberWithNullDisplayName() { + var membersWithNullName = List.of( + new GroupMember("agent-1", null, 0, "ROLE"), + new GroupMember("agent-2", "Writer", 1, "ROLE")); + + // Should still match by agentId + assertEquals("agent-1", TaskListParser.resolveAgent("agent-1", membersWithNullName)); + // Should not crash when iterating through null displayNames + assertEquals("agent-2", TaskListParser.resolveAgent("Writer", membersWithNullName)); + // Should return null for unmatched + assertNull(TaskListParser.resolveAgent("unknown", membersWithNullName)); + } +} diff --git a/src/test/java/ai/labs/eddi/engine/mcp/McpGroupToolsTest.java b/src/test/java/ai/labs/eddi/engine/mcp/McpGroupToolsTest.java index 4a5379d23..8b92b9b49 100644 --- a/src/test/java/ai/labs/eddi/engine/mcp/McpGroupToolsTest.java +++ b/src/test/java/ai/labs/eddi/engine/mcp/McpGroupToolsTest.java @@ -58,6 +58,7 @@ void describeDiscussionStyles_returnsNonEmptyDescription() { assertTrue(result.contains("DEVIL_ADVOCATE")); assertTrue(result.contains("DELPHI")); assertTrue(result.contains("DEBATE")); + assertTrue(result.contains("TASK_FORCE")); } // --- list_groups --- @@ -119,7 +120,7 @@ void readGroup_specificVersion() throws Exception { void createGroup_defaultStyle_usesRoundTable() throws Exception { when(groupStore.createGroup(any())).thenReturn(Response.created(URI.create("/groupstore/groups/new-id?version=1")).build()); - String result = tools.create_group("Panel", "desc", "a1,a2", "Alice,Bob", null, null, null, null, null, null); + String result = tools.create_group("Panel", "desc", "a1,a2", "Alice,Bob", null, null, null, null, null, null, null); assertTrue(result.contains("ROUND_TABLE")); assertTrue(result.contains("2 members")); @@ -140,7 +141,7 @@ void createGroup_defaultStyle_usesRoundTable() throws Exception { void createGroup_peerReviewStyle() throws Exception { when(groupStore.createGroup(any())).thenReturn(Response.created(URI.create("/groupstore/groups/id?version=1")).build()); - String result = tools.create_group("Review", null, "a1,a2,a3", null, null, null, "mod1", "PEER_REVIEW", "1", null); + String result = tools.create_group("Review", null, "a1,a2,a3", null, null, null, "mod1", "PEER_REVIEW", "1", null, null); assertTrue(result.contains("PEER_REVIEW")); @@ -158,7 +159,7 @@ void createGroup_withMemberRoles() throws Exception { when(groupStore.createGroup(any())).thenReturn(Response.created(URI.create("/groupstore/groups/id?version=1")).build()); tools.create_group("DA Panel", null, "a1,a2,a3", "Optimist,Pragmatist,Skeptic", "PARTICIPANT,PARTICIPANT,DEVIL_ADVOCATE", null, "mod1", - "DEVIL_ADVOCATE", null, null); + "DEVIL_ADVOCATE", null, null, null); ArgumentCaptor captor = ArgumentCaptor.forClass(AgentGroupConfiguration.class); verify(groupStore).createGroup(captor.capture()); @@ -173,7 +174,7 @@ void createGroup_withMemberRoles() throws Exception { void createGroup_invalidStyle_fallsBackToRoundTable() throws Exception { when(groupStore.createGroup(any())).thenReturn(Response.created(URI.create("/groupstore/groups/id")).build()); - tools.create_group("Test", null, "a1", null, null, null, null, "INVALID", null, null); + tools.create_group("Test", null, "a1", null, null, null, null, "INVALID", null, null, null); ArgumentCaptor captor = ArgumentCaptor.forClass(AgentGroupConfiguration.class); verify(groupStore).createGroup(captor.capture()); @@ -185,7 +186,7 @@ void createGroup_invalidStyle_fallsBackToRoundTable() throws Exception { void createGroup_handlesException() { when(groupStore.createGroup(any())).thenThrow(new RuntimeException("Insert failed")); - String result = tools.create_group("Test", null, "a1", null, null, null, null, null, null, null); + String result = tools.create_group("Test", null, "a1", null, null, null, null, null, null, null, null); assertTrue(result.contains("error")); } @@ -194,7 +195,7 @@ void createGroup_handlesException() { void createGroup_withGroupMembers() throws Exception { when(groupStore.createGroup(any())).thenReturn(Response.created(URI.create("/groupstore/groups/id?version=1")).build()); - tools.create_group("Meta Panel", null, "g1,g2", "Team A,Team B", null, "GROUP,GROUP", "mod1", "ROUND_TABLE", null, null); + tools.create_group("Meta Panel", null, "g1,g2", "Team A,Team B", null, "GROUP,GROUP", "mod1", "ROUND_TABLE", null, null, null); ArgumentCaptor captor = ArgumentCaptor.forClass(AgentGroupConfiguration.class); verify(groupStore).createGroup(captor.capture()); @@ -315,4 +316,108 @@ void listGroupConversations_handlesException() throws Exception { assertTrue(result.contains("error")); } + + // --- start_group_discussion (async) --- + + @Test + void startGroupDiscussion_returnsIdAndState() throws Exception { + GroupConversation gc = new GroupConversation(); + gc.setId("gc-async-1"); + gc.setState(GroupConversation.GroupConversationState.IN_PROGRESS); + when(groupConversationService.startAndDiscussAsync("g1", "Build it", "user1", null)).thenReturn(gc); + when(jsonSerialization.serialize(any(java.util.Map.class))).thenReturn( + "{\"groupConversationId\":\"gc-async-1\",\"state\":\"IN_PROGRESS\",\"message\":\"Discussion started.\"}"); + + String result = tools.start_group_discussion("g1", "Build it", "user1"); + + assertTrue(result.contains("gc-async-1"), "Should contain conversation ID"); + assertTrue(result.contains("IN_PROGRESS"), "Should indicate in-progress state"); + verify(groupConversationService).startAndDiscussAsync("g1", "Build it", "user1", null); + + // Verify the Map passed to serialize contains the right keys + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(java.util.Map.class); + verify(jsonSerialization).serialize(captor.capture()); + var map = captor.getValue(); + assertEquals("gc-async-1", map.get("groupConversationId")); + assertEquals("IN_PROGRESS", map.get("state")); + assertNotNull(map.get("message"), "Should include polling instructions"); + } + + @Test + void startGroupDiscussion_defaultsToMcpClient() throws Exception { + GroupConversation gc = new GroupConversation(); + gc.setId("gc-async-2"); + gc.setState(GroupConversation.GroupConversationState.IN_PROGRESS); + when(groupConversationService.startAndDiscussAsync(any(), any(), any(), any())).thenReturn(gc); + + tools.start_group_discussion("g1", "Q?", null); + + verify(groupConversationService).startAndDiscussAsync("g1", "Q?", "mcp-client", null); + } + + @Test + void startGroupDiscussion_handlesBlankUserId() throws Exception { + GroupConversation gc = new GroupConversation(); + gc.setId("gc-async-3"); + gc.setState(GroupConversation.GroupConversationState.IN_PROGRESS); + when(groupConversationService.startAndDiscussAsync(any(), any(), any(), any())).thenReturn(gc); + + tools.start_group_discussion("g1", "Q?", " "); + + verify(groupConversationService).startAndDiscussAsync("g1", "Q?", "mcp-client", null); + } + + @Test + void startGroupDiscussion_handlesException() throws Exception { + when(groupConversationService.startAndDiscussAsync(any(), any(), any(), any())) + .thenThrow(new RuntimeException("Group not found")); + + String result = tools.start_group_discussion("g1", "Q?", null); + + assertTrue(result.contains("error")); + assertTrue(result.contains("Group not found")); + } + + // --- delete_group_conversation --- + + @Test + void deleteGroupConversation_success() throws Exception { + tools.delete_group_conversation("gc-del-1"); + + verify(groupConversationService).deleteGroupConversation("gc-del-1"); + } + + @Test + void deleteGroupConversation_returnsConfirmation() throws Exception { + String result = tools.delete_group_conversation("gc-del-1"); + + assertEquals("Deleted group conversation gc-del-1", result); + } + + @Test + void deleteGroupConversation_handlesException() throws Exception { + doThrow(new RuntimeException("Not found")).when(groupConversationService).deleteGroupConversation("gc-bad"); + + String result = tools.delete_group_conversation("gc-bad"); + + assertTrue(result.contains("error")); + assertTrue(result.contains("Not found")); + } + + // --- @Blocking annotation --- + + @Test + void discussWithGroup_hasBlockingAnnotation() throws Exception { + var method = McpGroupTools.class.getMethod("discuss_with_group", String.class, String.class, String.class); + assertNotNull(method.getAnnotation(io.smallrye.common.annotation.Blocking.class), + "discuss_with_group must be annotated with @Blocking to avoid blocking the Vert.x event loop"); + } + + @Test + void startGroupDiscussion_doesNotHaveBlockingAnnotation() throws Exception { + var method = McpGroupTools.class.getMethod("start_group_discussion", String.class, String.class, String.class); + assertNull(method.getAnnotation(io.smallrye.common.annotation.Blocking.class), + "start_group_discussion is async and should NOT have @Blocking"); + } } diff --git a/src/test/java/ai/labs/eddi/engine/memory/ConversationOutputExtractorTest.java b/src/test/java/ai/labs/eddi/engine/memory/ConversationOutputExtractorTest.java new file mode 100644 index 000000000..159629e02 --- /dev/null +++ b/src/test/java/ai/labs/eddi/engine/memory/ConversationOutputExtractorTest.java @@ -0,0 +1,157 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.engine.memory; + +import ai.labs.eddi.engine.memory.model.ConversationOutput; +import ai.labs.eddi.engine.memory.model.SimpleConversationMemorySnapshot; +import org.junit.jupiter.api.Test; + +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link ConversationOutputExtractor} — the shared utility that + * extracts human-readable text from conversation memory snapshots. + */ +class ConversationOutputExtractorTest { + + // --- Null / empty input --- + + @Test + void extractResponse_nullSnapshot_returnsNull() { + assertNull(ConversationOutputExtractor.extractResponse(null)); + } + + @Test + void extractResponse_nullOutputs_returnsNull() { + var snapshot = new SimpleConversationMemorySnapshot(); + snapshot.setConversationOutputs(null); + assertNull(ConversationOutputExtractor.extractResponse(snapshot)); + } + + @Test + void extractResponse_emptyOutputs_returnsNull() { + var snapshot = new SimpleConversationMemorySnapshot(); + snapshot.setConversationOutputs(new LinkedList<>()); + assertNull(ConversationOutputExtractor.extractResponse(snapshot)); + } + + // --- Format 1: Nested "output" array with plain strings --- + + @Test + void extractResponse_outputArrayWithStrings_joinsThem() { + var output = new ConversationOutput(); + output.put("output", List.of("Hello", "World")); + var snapshot = snapshotWith(output); + + assertEquals("Hello\nWorld", ConversationOutputExtractor.extractResponse(snapshot)); + } + + @Test + void extractResponse_outputArrayWithSingleString() { + var output = new ConversationOutput(); + output.put("output", List.of("Only one")); + var snapshot = snapshotWith(output); + + assertEquals("Only one", ConversationOutputExtractor.extractResponse(snapshot)); + } + + // --- Format 1: Nested "output" array with Map items (text key) --- + + @Test + void extractResponse_outputArrayWithMapItems_extractsTextKey() { + var output = new ConversationOutput(); + output.put("output", List.of(Map.of("text", "From map"), Map.of("text", "Another"))); + var snapshot = snapshotWith(output); + + assertEquals("From map\nAnother", ConversationOutputExtractor.extractResponse(snapshot)); + } + + @Test + void extractResponse_outputArrayWithMixedItems() { + var output = new ConversationOutput(); + output.put("output", List.of("Plain text", Map.of("text", "Map text"))); + var snapshot = snapshotWith(output); + + assertEquals("Plain text\nMap text", ConversationOutputExtractor.extractResponse(snapshot)); + } + + // --- Format 2: Flat keys like "output:text:*" --- + + @Test + void extractResponse_flatOutputTextKey_extractsValue() { + var output = new ConversationOutput(); + output.put("output:text:greeting", "Hello from flat key"); + var snapshot = snapshotWith(output); + + assertEquals("Hello from flat key", ConversationOutputExtractor.extractResponse(snapshot)); + } + + @Test + void extractResponse_flatOutputTextKey_withListValue() { + var output = new ConversationOutput(); + output.put("output:text:items", List.of("First", "Second")); + var snapshot = snapshotWith(output); + + assertEquals("First\nSecond", ConversationOutputExtractor.extractResponse(snapshot)); + } + + // --- Metadata-only output (no "output" or "reply" keys) --- + + @Test + void extractResponse_metadataOnly_returnsNull() { + var output = new ConversationOutput(); + output.put("actions", List.of("greet")); + output.put("input", "Hello"); + var snapshot = snapshotWith(output); + + assertNull(ConversationOutputExtractor.extractResponse(snapshot)); + } + + // --- Fallback: output key exists but not extractable as text --- + + @Test + void extractResponse_fallbackToString() { + var output = new ConversationOutput(); + output.put("output", 42); // Not a List → falls through format 1 + var snapshot = snapshotWith(output); + + // Should hit fallback toString() because "output" key exists + String result = ConversationOutputExtractor.extractResponse(snapshot); + assertNotNull(result, "Should fall back to toString()"); + assertTrue(result.contains("output"), "Fallback should include the output key"); + } + + // --- Multiple outputs: always uses the LAST one --- + + @Test + void extractResponse_multipleOutputs_usesLast() { + var first = new ConversationOutput(); + first.put("output", List.of("First output")); + var second = new ConversationOutput(); + second.put("output", List.of("Second output")); + var outputs = new LinkedList(); + outputs.add(first); + outputs.add(second); + + var snapshot = new SimpleConversationMemorySnapshot(); + snapshot.setConversationOutputs(outputs); + + assertEquals("Second output", ConversationOutputExtractor.extractResponse(snapshot)); + } + + // --- Helper --- + + private static SimpleConversationMemorySnapshot snapshotWith(ConversationOutput output) { + var outputs = new LinkedList(); + outputs.add(output); + var snapshot = new SimpleConversationMemorySnapshot(); + snapshot.setConversationOutputs(outputs); + return snapshot; + } +} diff --git a/src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorBranchTest.java b/src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorBranchTest.java index 903ac7c95..255b4fd89 100644 --- a/src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorBranchTest.java +++ b/src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorBranchTest.java @@ -100,7 +100,8 @@ void setUp() { restAgentStore, restWorkflowStore, resourceClientLibrary, apiCallExecutor, jsonSerialization, memoryItemConverter, userMemoryStore, toolResponseTruncator, tenantQuotaService, - memorySnapshotService); + memorySnapshotService, + null, null, null, null, null); } // ========================================================= diff --git a/src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorExtendedBranchTest.java b/src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorExtendedBranchTest.java index 2983b649d..9c479d871 100644 --- a/src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorExtendedBranchTest.java +++ b/src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorExtendedBranchTest.java @@ -102,7 +102,8 @@ void setUp() { restAgentStore, restWorkflowStore, resourceClientLibrary, apiCallExecutor, jsonSerialization, memoryItemConverter, userMemoryStore, toolResponseTruncator, tenantQuotaService, - memorySnapshotService); + memorySnapshotService, + null, null, null, null, null); } // ========================================================= @@ -221,7 +222,8 @@ void nullStoreSkipsTool() { restAgentStore, restWorkflowStore, resourceClientLibrary, apiCallExecutor, jsonSerialization, memoryItemConverter, null, // null userMemoryStore - toolResponseTruncator, tenantQuotaService, memorySnapshotService); + toolResponseTruncator, tenantQuotaService, memorySnapshotService, + null, null, null, null, null); var task = new LlmConfiguration.Task(); task.setEnableBuiltInTools(true); diff --git a/src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorExtendedTest.java b/src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorExtendedTest.java index d544d6dbf..8b07785fd 100644 --- a/src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorExtendedTest.java +++ b/src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorExtendedTest.java @@ -94,7 +94,8 @@ void setUp() { mock(IResourceClientLibrary.class), mock(IApiCallExecutor.class), mock(IJsonSerialization.class), mock(IMemoryItemConverter.class), userMemoryStore, mock(ToolResponseTruncator.class), - mock(TenantQuotaService.class), memorySnapshotService); + mock(TenantQuotaService.class), memorySnapshotService, + null, null, null, null, null); mockMemory = mock(IConversationMemory.class); when(mockMemory.getUserMemoryConfig()).thenReturn(null); diff --git a/src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorTest.java b/src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorTest.java index ebafbbd06..066113580 100644 --- a/src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorTest.java +++ b/src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorTest.java @@ -110,7 +110,8 @@ void setUp() { restAgentStore, restWorkflowStore, resourceClientLibrary, apiCallExecutor, jsonSerialization, memoryItemConverter, userMemoryStore, toolResponseTruncator, tenantQuotaService, - memorySnapshotService); + memorySnapshotService, + null, null, null, null, null); } // ═══════════════════════════════════════════════════════════════════ @@ -519,7 +520,8 @@ void collectEnabledTools_userMemoryNullStore_notAdded() { restAgentStore, restWorkflowStore, resourceClientLibrary, apiCallExecutor, jsonSerialization, memoryItemConverter, null, // null userMemoryStore - toolResponseTruncator, tenantQuotaService, memorySnapshotService); + toolResponseTruncator, tenantQuotaService, memorySnapshotService, + null, null, null, null, null); var task = new LlmConfiguration.Task(); task.setEnableBuiltInTools(true); diff --git a/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskBranchTest.java b/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskBranchTest.java index 117149a52..3da5acc12 100644 --- a/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskBranchTest.java +++ b/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskBranchTest.java @@ -116,7 +116,8 @@ public ChatResponse chat(List messages) { mockSnippetService, globalVariableResolver, counterweightService, identityMaskingService, toolResponseTruncator, mock(ai.labs.eddi.engine.tenancy.TenantQuotaService.class), - null, null); + null, null, + null, null, null, null, null); } private IConversationMemory setupMemory(List actions) { diff --git a/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskConfigureTest.java b/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskConfigureTest.java index 1591d479a..5b827288f 100644 --- a/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskConfigureTest.java +++ b/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskConfigureTest.java @@ -115,7 +115,8 @@ public ChatResponse chat(List messages) { mockSnippetService, globalVariableResolver, counterweightService, identityMaskingService, toolResponseTruncator, mock(ai.labs.eddi.engine.tenancy.TenantQuotaService.class), - null, null); + null, null, + null, null, null, null, null); } // ==================== getId ==================== diff --git a/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskDeepBranchTest.java b/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskDeepBranchTest.java index d799d5f22..1735eff7e 100644 --- a/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskDeepBranchTest.java +++ b/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskDeepBranchTest.java @@ -130,7 +130,8 @@ public ChatResponse chat(List messages) { mockSnippetService, globalVariableResolver, counterweightService, identityMaskingService, toolResponseTruncator, mock(ai.labs.eddi.engine.tenancy.TenantQuotaService.class), - null, null); + null, null, + null, null, null, null, null); } private IConversationMemory setupMemory(List actions) { @@ -514,7 +515,8 @@ public ChatResponse chat(List messages) { new TokenCounterFactory(), mock(ConversationSummarizer.class), mockSnippetService, gvr, cws, ims, trt, mock(ai.labs.eddi.engine.tenancy.TenantQuotaService.class), - null, null); + null, null, + null, null, null, null, null); var memory = setupMemory(List.of("action1")); when(memoryItemConverter.convert(memory)).thenReturn(new HashMap<>()); diff --git a/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskExtendedBranchTest.java b/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskExtendedBranchTest.java index c2645c58a..80760f7b5 100644 --- a/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskExtendedBranchTest.java +++ b/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskExtendedBranchTest.java @@ -122,7 +122,8 @@ public ChatResponse chat(List messages) { mockSnippetService, globalVariableResolver, counterweightService, identityMaskingService, toolResponseTruncator, mock(ai.labs.eddi.engine.tenancy.TenantQuotaService.class), - null, null); + null, null, + null, null, null, null, null); } private IConversationMemory setupMemory(List actions) { @@ -495,7 +496,8 @@ public ChatResponse chat(List messages) { new TokenCounterFactory(), mock(ConversationSummarizer.class), snippetService, gvr, cws, ims, trt, mock(ai.labs.eddi.engine.tenancy.TenantQuotaService.class), - null, null); + null, null, + null, null, null, null, null); var memory = setupMemory(List.of("action1")); when(memoryItemConverter.convert(memory)).thenReturn(new HashMap<>()); diff --git a/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskExtendedTest.java b/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskExtendedTest.java index 99b5995b0..c3483048e 100644 --- a/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskExtendedTest.java +++ b/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskExtendedTest.java @@ -123,7 +123,8 @@ public ChatResponse chat(List messages) { promptSnippetService, globalVariableResolver, counterweightService, identityMaskingService, toolResponseTruncator, mock(ai.labs.eddi.engine.tenancy.TenantQuotaService.class), - null, null); + null, null, + null, null, null, null, null); } private IConversationMemory createMemoryWithAction(String... actions) throws Exception { diff --git a/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskTest.java b/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskTest.java index 47a5c819c..68617109c 100644 --- a/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskTest.java +++ b/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskTest.java @@ -142,7 +142,8 @@ void setUp() { counterweightService, identityMaskingService, toolResponseTruncator, tenantQuotaService, - memorySnapshotService, attachmentStore); + memorySnapshotService, attachmentStore, + null, null, null, null, null); } // ==================================================================== diff --git a/src/test/java/ai/labs/eddi/modules/llm/tools/DynamicAgentToolsTest.java b/src/test/java/ai/labs/eddi/modules/llm/tools/DynamicAgentToolsTest.java new file mode 100644 index 000000000..ec49ca653 --- /dev/null +++ b/src/test/java/ai/labs/eddi/modules/llm/tools/DynamicAgentToolsTest.java @@ -0,0 +1,1199 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.modules.llm.tools; + +import ai.labs.eddi.configs.agents.CapabilityRegistryService; +import ai.labs.eddi.configs.agents.CapabilityRegistryService.CapabilityMatch; +import ai.labs.eddi.configs.agents.IAgentStore; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration; +import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.DynamicAgentConfig; +import ai.labs.eddi.engine.api.IConversationService; +import ai.labs.eddi.engine.api.IConversationService.ConversationResponseHandler; +import ai.labs.eddi.engine.api.IConversationService.ConversationResult; +import ai.labs.eddi.engine.memory.model.ConversationOutput; +import ai.labs.eddi.engine.memory.model.ConversationState; +import ai.labs.eddi.engine.memory.model.SimpleConversationMemorySnapshot; +import ai.labs.eddi.engine.model.Deployment.Environment; +import ai.labs.eddi.engine.model.InputData; +import ai.labs.eddi.engine.runtime.IAgentFactory; +import ai.labs.eddi.engine.setup.AgentSetupService; +import ai.labs.eddi.engine.setup.AgentSetupService.AgentSetupException; +import ai.labs.eddi.engine.setup.SetupAgentRequest; +import ai.labs.eddi.engine.setup.SetupResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.*; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeoutException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Tests for the Dynamic Agent LLM tools: CreateSubAgentTool, + * ConverseWithAgentTool, FindAgentsByCapabilityTool, and TeardownAgentTool. + * + * @since 6.0.0 + */ +class DynamicAgentToolsTest { + + // === CreateSubAgentTool === + + @Nested + class CreateSubAgentToolTest { + + private AgentSetupService agentSetupService; + private IConversationService conversationService; + private DynamicAgentConfig config; + private List createdAgentIds; + private Set retainedAgentIds; + private CreateSubAgentTool tool; + + @BeforeEach + void setUp() { + agentSetupService = mock(AgentSetupService.class); + conversationService = mock(IConversationService.class); + config = new DynamicAgentConfig(); + config.setEnabled(true); + config.setAllowCreation(true); + config.setMaxCreatedAgentsPerDiscussion(5); + createdAgentIds = new CopyOnWriteArrayList<>(); + retainedAgentIds = ConcurrentHashMap.newKeySet(); + tool = new CreateSubAgentTool(agentSetupService, + conversationService, "parent-agent-1", "user-1", config, createdAgentIds, retainedAgentIds); + } + + @Test + void createSubAgent_success() throws Exception { + when(agentSetupService.setupAgent(any(SetupAgentRequest.class))) + .thenReturn(new SetupResult("created", "sub-agent-1", "parent-agent-1/DataAnalyst", + "anthropic", "claude-sonnet-4-6", true, "ready", null, null, null, null, null)); + + String result = tool.createSubAgent("DataAnalyst", "You analyze data", "anthropic", "claude-sonnet-4-6", null, null); + + assertTrue(result.contains("✅")); + assertTrue(result.contains("sub-agent-1")); + assertEquals(1, createdAgentIds.size()); + assertEquals("sub-agent-1", createdAgentIds.get(0)); + } + + @Test + void createSubAgent_creationDisabled() { + config.setAllowCreation(false); + + String result = tool.createSubAgent("Test", "prompt", null, null, null, null); + + assertTrue(result.contains("⚠️")); + assertTrue(result.contains("not enabled")); + assertTrue(createdAgentIds.isEmpty()); + } + + @Test + void createSubAgent_maxReached() throws Exception { + config.setMaxCreatedAgentsPerDiscussion(2); + createdAgentIds.add("agent-1"); + createdAgentIds.add("agent-2"); + + String result = tool.createSubAgent("Test", "prompt", null, null, null, null); + + assertTrue(result.contains("⚠️")); + assertTrue(result.contains("Maximum created agents")); + verify(agentSetupService, never()).setupAgent(any()); + } + + @Test + void createSubAgent_providerNotAllowed() { + config.setAllowedProviders(List.of("openai")); + + String result = tool.createSubAgent("Test", "prompt", "anthropic", null, null, null); + + assertTrue(result.contains("⚠️")); + assertTrue(result.contains("not allowed")); + } + + @Test + void createSubAgent_modelNotAllowed() { + config.setAllowedProviders(List.of("openai")); + config.setAllowedModels(Map.of("openai", List.of("gpt-4o-mini"))); + + String result = tool.createSubAgent("Test", "prompt", "openai", "gpt-4o", null, null); + + assertTrue(result.contains("⚠️")); + assertTrue(result.contains("not allowed")); + } + + @Test + void createSubAgent_modelNotAllowed_providerOmitted() { + // When provider is omitted, model must still be checked against all provider + // lists + config.setAllowedModels(Map.of("openai", List.of("gpt-4o-mini"))); + + String result = tool.createSubAgent("Test", "prompt", null, "unknown-model", null, null); + + assertTrue(result.contains("⚠️")); + assertTrue(result.contains("not in any provider")); + } + + @Test + void createSubAgent_quotaEnforcedByConversationStart() throws Exception { + // Quota is enforced by startConversation() internally, not by + // CreateSubAgentTool. + // The tool no longer holds a TenantQuotaService reference at all. + when(agentSetupService.setupAgent(any(SetupAgentRequest.class))) + .thenReturn(new SetupResult("created", "sub-agent-1", "parent-agent-1/Test", + null, null, true, "ready", null, null, null, null, null)); + + String result = tool.createSubAgent("Test", "prompt", null, null, null, null); + + assertTrue(result.contains("✅")); + } + + @Test + void createSubAgent_nameRequired() { + String result = tool.createSubAgent(null, "prompt", null, null, null, null); + assertTrue(result.contains("⚠️")); + assertTrue(result.contains("name is required")); + } + + @Test + void createSubAgent_promptRequired() { + String result = tool.createSubAgent("Test", null, null, null, null, null); + assertTrue(result.contains("⚠️")); + assertTrue(result.contains("prompt is required")); + } + + @Test + void createSubAgent_setupFailure() throws Exception { + when(agentSetupService.setupAgent(any(SetupAgentRequest.class))) + .thenThrow(new AgentSetupException("DB error")); + + String result = tool.createSubAgent("Test", "prompt", null, null, null, null); + + assertTrue(result.contains("❌")); + assertTrue(result.contains("DB error")); + assertTrue(createdAgentIds.isEmpty()); + } + + @Test + void createSubAgent_retainFlag() throws Exception { + when(agentSetupService.setupAgent(any(SetupAgentRequest.class))) + .thenReturn(new SetupResult("created", "sub-agent-1", "parent-agent-1/Test", + null, null, true, "ready", null, null, null, null, null)); + + String result = tool.createSubAgent("Test", "prompt", null, null, null, true); + + assertTrue(result.contains("✅")); + assertTrue(result.contains("retained")); + assertTrue(retainedAgentIds.contains("sub-agent-1")); + } + + @Test + void createSubAgent_modelAllowed_caseInsensitiveKey() throws Exception { + // Config uses mixed-case key "OpenAI", but caller passes lowercase "openai" + config.setAllowedProviders(List.of("openai")); + config.setAllowedModels(Map.of("OpenAI", List.of("gpt-4o-mini"))); + + when(agentSetupService.setupAgent(any(SetupAgentRequest.class))) + .thenReturn(new SetupResult("created", "sub-agent-1", "parent-agent-1/Test", + "openai", "gpt-4o-mini", true, "ready", null, null, null, null, null)); + + String result = tool.createSubAgent("Test", "prompt", "openai", "gpt-4o-mini", null, null); + + assertTrue(result.contains("✅")); + } + + @Test + void createSubAgent_modelAllowed_providerOmitted_positiveCase() throws Exception { + // Model exists in one of the provider lists — should be allowed + config.setAllowedModels(Map.of("openai", List.of("gpt-4o-mini"))); + + when(agentSetupService.setupAgent(any(SetupAgentRequest.class))) + .thenReturn(new SetupResult("created", "sub-agent-1", "parent-agent-1/Test", + null, "gpt-4o-mini", true, "ready", null, null, null, null, null)); + + String result = tool.createSubAgent("Test", "prompt", null, "gpt-4o-mini", null, null); + + assertTrue(result.contains("✅")); + } + + @Test + void createSubAgent_configDisabled() { + config.setEnabled(false); + config.setAllowCreation(true); + + String result = tool.createSubAgent("Test", "prompt", null, null, null, null); + + assertTrue(result.contains("⚠️")); + assertTrue(result.contains("not enabled")); + } + + @Test + void createSubAgent_withInitialMessage_success() throws Exception { + when(agentSetupService.setupAgent(any(SetupAgentRequest.class))) + .thenReturn(new SetupResult("created", "sub-agent-1", "parent-agent-1/Test", + "openai", "gpt-4o-mini", true, "ready", null, null, null, null, null)); + + // Stub startConversation + when(conversationService.startConversation(any(Environment.class), eq("sub-agent-1"), eq("user-1"), anyMap())) + .thenReturn(new ConversationResult("conv-123", null)); + + // Stub say() — callback with snapshot containing output + var snapshot = new SimpleConversationMemorySnapshot(); + snapshot.setConversationId("conv-123"); + snapshot.setConversationState(ConversationState.READY); + var output = new ConversationOutput(); + output.put("output", List.of("Hello from sub-agent!")); + snapshot.setConversationOutputs(List.of(output)); + + doAnswer(invocation -> { + IConversationService.ConversationResponseHandler handler = invocation.getArgument(8); + handler.onComplete(snapshot); + return null; + }).when(conversationService).say( + any(), any(), any(), anyBoolean(), anyBoolean(), any(), any(), anyBoolean(), any()); + + String result = tool.createSubAgent("Test", "prompt", "openai", "gpt-4o-mini", "hi", null); + + assertTrue(result.contains("✅")); + assertTrue(result.contains("conv-123")); + assertTrue(result.contains("Hello from sub-agent!")); + assertTrue(result.contains("Provider: openai")); + assertTrue(result.contains("Model: gpt-4o-mini")); + } + + @Test + void createSubAgent_withInitialMessage_failure() throws Exception { + when(agentSetupService.setupAgent(any(SetupAgentRequest.class))) + .thenReturn(new SetupResult("created", "sub-agent-1", "parent-agent-1/Test", + null, null, true, "ready", null, null, null, null, null)); + + // Stub startConversation to throw + when(conversationService.startConversation(any(Environment.class), eq("sub-agent-1"), eq("user-1"), anyMap())) + .thenThrow(new RuntimeException("Not deployed")); + + String result = tool.createSubAgent("Test", "prompt", null, null, "hi", null); + + assertTrue(result.contains("✅")); // Agent was created successfully + assertTrue(result.contains("Initial message failed")); + } + + @Test + void createSubAgent_noProviderNoModel_omitsFromResult() throws Exception { + when(agentSetupService.setupAgent(any(SetupAgentRequest.class))) + .thenReturn(new SetupResult("created", "sub-agent-1", "parent-agent-1/Test", + null, null, true, "ready", null, null, null, null, null)); + + String result = tool.createSubAgent("Test", "prompt", null, null, null, null); + + assertTrue(result.contains("✅")); + assertFalse(result.contains("Provider:")); + assertFalse(result.contains("Model:")); + } + + @Test + void createSubAgent_unexpectedException() throws Exception { + when(agentSetupService.setupAgent(any(SetupAgentRequest.class))) + .thenThrow(new RuntimeException("Unexpected")); + + String result = tool.createSubAgent("Test", "prompt", null, null, null, null); + + assertTrue(result.contains("❌")); + assertTrue(result.contains("Unexpected")); + } + + @Test + void createSubAgent_blankName() { + String result = tool.createSubAgent(" ", "prompt", null, null, null, null); + assertTrue(result.contains("⚠️")); + assertTrue(result.contains("name is required")); + } + + @Test + void createSubAgent_blankPrompt() { + String result = tool.createSubAgent("Test", " ", null, null, null, null); + assertTrue(result.contains("⚠️")); + assertTrue(result.contains("prompt is required")); + } + + @Test + void createSubAgent_emptyProviderAllowList() throws Exception { + // Empty (not null) allow list should not restrict + config.setAllowedProviders(List.of()); + + when(agentSetupService.setupAgent(any(SetupAgentRequest.class))) + .thenReturn(new SetupResult("created", "sub-agent-1", "parent-agent-1/Test", + "anthropic", null, true, "ready", null, null, null, null, null)); + + String result = tool.createSubAgent("Test", "prompt", "anthropic", null, null, null); + assertTrue(result.contains("✅")); + } + + @Test + void createSubAgent_emptyModelAllowList() throws Exception { + // Empty (not null) model allow list should not restrict + config.setAllowedModels(Map.of()); + + when(agentSetupService.setupAgent(any(SetupAgentRequest.class))) + .thenReturn(new SetupResult("created", "sub-agent-1", "parent-agent-1/Test", + null, "any-model", true, "ready", null, null, null, null, null)); + + String result = tool.createSubAgent("Test", "prompt", null, "any-model", null, null); + assertTrue(result.contains("✅")); + } + + @Test + void createSubAgent_retainFalse_notTracked() throws Exception { + when(agentSetupService.setupAgent(any(SetupAgentRequest.class))) + .thenReturn(new SetupResult("created", "sub-agent-1", "parent-agent-1/Test", + null, null, true, "ready", null, null, null, null, null)); + + String result = tool.createSubAgent("Test", "prompt", null, null, null, false); + + assertTrue(result.contains("✅")); + assertFalse(result.contains("retained")); + assertTrue(retainedAgentIds.isEmpty()); + } + + @Test + void createSubAgent_withInitialMessage_extractResponseMapFormat() throws Exception { + when(agentSetupService.setupAgent(any(SetupAgentRequest.class))) + .thenReturn(new SetupResult("created", "sub-agent-1", "parent-agent-1/Test", + null, null, true, "ready", null, null, null, null, null)); + + when(conversationService.startConversation(any(Environment.class), eq("sub-agent-1"), eq("user-1"), anyMap())) + .thenReturn(new ConversationResult("conv-123", null)); + + // Snapshot with Map-format output items ("text" key) + var snapshot = new SimpleConversationMemorySnapshot(); + snapshot.setConversationId("conv-123"); + snapshot.setConversationState(ConversationState.READY); + var output = new ConversationOutput(); + output.put("output", List.of(Map.of("text", "Map-format response"))); + snapshot.setConversationOutputs(List.of(output)); + + doAnswer(invocation -> { + IConversationService.ConversationResponseHandler handler = invocation.getArgument(8); + handler.onComplete(snapshot); + return null; + }).when(conversationService).say( + any(), any(), any(), anyBoolean(), anyBoolean(), any(), any(), anyBoolean(), any()); + + String result = tool.createSubAgent("Test", "prompt", null, null, "hi", null); + + assertTrue(result.contains("Map-format response")); + } + + // --- Null-safety tests (Copilot PR review fixes) --- + + @Test + @DisplayName("Fix 2: null DynamicAgentConfig defaults to disabled config") + void createSubAgent_nullConfig_defaultsToDisabled() { + // Construct with null config — should not NPE + var toolWithNullConfig = new CreateSubAgentTool(agentSetupService, + conversationService, "parent-1", "user-1", null, createdAgentIds, retainedAgentIds); + + // Default DynamicAgentConfig has enabled=false, so creation should be blocked + String result = toolWithNullConfig.createSubAgent("Test", "prompt", null, null, null, null); + assertTrue(result.contains("⚠️"), "Null config should default to disabled"); + assertTrue(result.contains("not enabled"), "Should indicate feature is not enabled"); + } + + @Test + @DisplayName("Fix 3: null entries in allowedProviders list don't cause NPE") + void createSubAgent_nullEntryInProviderAllowList() { + // Simulates malformed JSON config with null entries + var providers = new java.util.ArrayList(); + providers.add(null); + providers.add("openai"); + config.setAllowedProviders(providers); + + String result = tool.createSubAgent("Test", "prompt", "anthropic", null, null, null); + assertTrue(result.contains("⚠️"), "Should still enforce allow-list"); + assertTrue(result.contains("not allowed")); + } + + @Test + @DisplayName("Fix 4: null values in allowedModels map don't cause NPE (with provider)") + void createSubAgent_nullModelListInAllowedModels_withProvider() throws Exception { + // Map with a provider key mapping to null list + var models = new java.util.HashMap>(); + models.put("openai", null); // null list for provider + config.setAllowedModels(models); + config.setAllowedProviders(List.of("openai")); + + when(agentSetupService.setupAgent(any(SetupAgentRequest.class))) + .thenReturn(new SetupResult("created", "sub-agent-1", "parent-agent-1/Test", + "openai", "gpt-4o", true, "ready", null, null, null, null, null)); + + // Should not NPE — null list is filtered, so model check is skipped + String result = tool.createSubAgent("Test", "prompt", "openai", "gpt-4o", null, null); + assertTrue(result.contains("✅"), "Null model list should be treated as no restriction"); + } + + @Test + @DisplayName("Fix 4: null values in allowedModels map don't cause NPE (without provider)") + void createSubAgent_nullModelListInAllowedModels_noProvider() { + // Map with a provider key mapping to null list + var models = new java.util.HashMap>(); + models.put("openai", null); + config.setAllowedModels(models); + + // Without provider, the check iterates ALL provider values.stream().flatMap() + // A null list would cause NPE without the Objects::nonNull filter. + String result = tool.createSubAgent("Test", "prompt", null, "some-model", null, null); + // Should not NPE; model not found in any list → blocked + assertTrue(result.contains("⚠️")); + } + + @Test + @DisplayName("Fix 4: null model entries in allowedModels list don't cause NPE") + void createSubAgent_nullModelEntryInList() { + var modelList = new java.util.ArrayList(); + modelList.add(null); + modelList.add("gpt-4o"); + config.setAllowedModels(Map.of("openai", modelList)); + + // Provider omitted — checks all values; null entries should be filtered + String result = tool.createSubAgent("Test", "prompt", null, "unknown-model", null, null); + assertTrue(result.contains("⚠️"), "Should still enforce allow-list despite null entries"); + } + + @Test + @DisplayName("Constructor with null tracking lists defaults to empty collections") + void createSubAgent_nullTrackingLists() throws Exception { + var toolNullLists = new CreateSubAgentTool(agentSetupService, + conversationService, "parent-1", "user-1", config, null, null); + + when(agentSetupService.setupAgent(any(SetupAgentRequest.class))) + .thenReturn(new SetupResult("created", "sub-agent-1", "parent-agent-1/Test", + null, null, true, "ready", null, null, null, null, null)); + + // Should not NPE + String result = toolNullLists.createSubAgent("Test", "prompt", null, null, null, null); + assertTrue(result.contains("✅")); + } + } + + // === ConverseWithAgentTool === + + @Nested + @DisplayName("ConverseWithAgentTool") + class ConverseWithAgentToolTest { + + private IConversationService conversationService; + private ConverseWithAgentTool tool; + + private static final String USER_ID = "test-user-1"; + private static final String AGENT_ID = "target-agent-1"; + private static final String CONVERSATION_ID = "conv-12345"; + + @BeforeEach + void setUp() { + conversationService = mock(IConversationService.class); + tool = new ConverseWithAgentTool(conversationService, USER_ID); + } + + /** + * Helper: create a snapshot with the given outputs and conversation state. + */ + private SimpleConversationMemorySnapshot createSnapshot(ConversationState state, + List outputTexts) { + var snapshot = new SimpleConversationMemorySnapshot(); + snapshot.setConversationId(CONVERSATION_ID); + snapshot.setConversationState(state); + + if (outputTexts != null && !outputTexts.isEmpty()) { + var output = new ConversationOutput(); + output.put("output", outputTexts); + snapshot.setConversationOutputs(List.of(output)); + } + + return snapshot; + } + + /** + * Helper: stub conversationService.say() to invoke the callback with the given + * snapshot. + */ + private void stubSayWithSnapshot(SimpleConversationMemorySnapshot snapshot) throws Exception { + doAnswer(invocation -> { + ConversationResponseHandler handler = invocation.getArgument(8); + handler.onComplete(snapshot); + return null; + }).when(conversationService).say( + any(), any(), any(), anyBoolean(), anyBoolean(), any(), any(), anyBoolean(), any()); + } + + @Test + @DisplayName("New conversation: starts conversation + sends message + returns response") + void converseWithAgent_newConversation_success() throws Exception { + // Arrange — startConversation returns a new conversation + when(conversationService.startConversation(any(Environment.class), eq(AGENT_ID), eq(USER_ID), anyMap())) + .thenReturn(new ConversationResult(CONVERSATION_ID, URI.create("/conversations/" + CONVERSATION_ID))); + + var snapshot = createSnapshot(ConversationState.READY, List.of("Hello from agent!")); + stubSayWithSnapshot(snapshot); + + // Act + String result = tool.converseWithAgent(AGENT_ID, "Hi there", null); + + // Assert + assertTrue(result.contains("✅"), "Should contain success marker"); + assertTrue(result.contains(CONVERSATION_ID), "Should contain conversation ID"); + assertTrue(result.contains("Hello from agent!"), "Should contain agent response text"); + + verify(conversationService).startConversation(any(Environment.class), eq(AGENT_ID), eq(USER_ID), anyMap()); + verify(conversationService).say( + any(), eq(AGENT_ID), eq(CONVERSATION_ID), anyBoolean(), anyBoolean(), any(), any(), anyBoolean(), any()); + } + + @Test + @DisplayName("Existing conversation: reuses conversationId, does not start new") + void converseWithAgent_existingConversation_success() throws Exception { + // Arrange — provide an existing conversation ID + var snapshot = createSnapshot(ConversationState.READY, List.of("Follow-up response")); + stubSayWithSnapshot(snapshot); + + // Act + String result = tool.converseWithAgent(AGENT_ID, "Follow-up question", CONVERSATION_ID); + + // Assert + assertTrue(result.contains("✅"), "Should contain success marker"); + assertTrue(result.contains(CONVERSATION_ID), "Should contain conversation ID"); + assertTrue(result.contains("Follow-up response"), "Should contain agent response text"); + + // Must NOT start a new conversation + verify(conversationService, never()).startConversation(any(), any(), any(), any()); + verify(conversationService).say( + any(), eq(AGENT_ID), eq(CONVERSATION_ID), anyBoolean(), anyBoolean(), any(), any(), anyBoolean(), any()); + } + + @Test + @DisplayName("Null agentId returns warning") + void converseWithAgent_nullAgentId_returnsWarning() { + String result = tool.converseWithAgent(null, "Hello", null); + + assertTrue(result.contains("⚠️"), "Should contain warning marker"); + assertTrue(result.contains("Agent ID is required"), "Should mention agent ID required"); + verifyNoInteractions(conversationService); + } + + @Test + @DisplayName("Null message returns warning") + void converseWithAgent_nullMessage_returnsWarning() { + String result = tool.converseWithAgent(AGENT_ID, null, null); + + assertTrue(result.contains("⚠️"), "Should contain warning marker"); + assertTrue(result.contains("Message is required"), "Should mention message required"); + verifyNoInteractions(conversationService); + } + + @Test + @DisplayName("Timeout waiting for agent response returns warning") + void converseWithAgent_timeout_returnsWarning() throws Exception { + // Arrange — startConversation succeeds but say() never invokes the callback + when(conversationService.startConversation(any(Environment.class), eq(AGENT_ID), eq(USER_ID), anyMap())) + .thenReturn(new ConversationResult(CONVERSATION_ID, URI.create("/conversations/" + CONVERSATION_ID))); + + // say() does NOT invoke the callback — the CompletableFuture will time out. + // We simulate this by making say() throw a TimeoutException wrapped in + // ExecutionException + // Actually the production code calls responseFuture.get(60, TimeUnit.SECONDS) + // which throws TimeoutException. + // We need say() to simply not call the handler. But we can't wait 60s in a + // test. + // Instead, we make say() throw an exception that gets caught as + // TimeoutException. + doAnswer(invocation -> { + // Don't invoke the handler — but we can't wait 60s. + // Instead, let's directly throw to simulate the scenario via the outer catch. + throw new java.util.concurrent.TimeoutException("Simulated timeout"); + }).when(conversationService).say( + any(), any(), any(), anyBoolean(), anyBoolean(), any(), any(), anyBoolean(), any()); + + // Act + String result = tool.converseWithAgent(AGENT_ID, "Hello", null); + + // Assert — the outer catch handles TimeoutException + assertTrue(result.contains("⚠️") || result.contains("❌"), + "Should contain a warning or error marker"); + assertTrue(result.toLowerCase().contains("timeout") || result.toLowerCase().contains("error"), + "Should mention timeout or error"); + } + + @Test + @DisplayName("Agent ID required") + void converseWithAgent_agentIdRequired() { + String result = tool.converseWithAgent(null, "Hello", null); + assertTrue(result.contains("⚠️")); + assertTrue(result.contains("Agent ID is required")); + } + + @Test + @DisplayName("Blank agent ID rejected") + void converseWithAgent_blankAgentId() { + String result = tool.converseWithAgent(" ", "Hello", null); + assertTrue(result.contains("⚠️")); + } + + @Test + @DisplayName("Message required") + void converseWithAgent_messageRequired() { + String result = tool.converseWithAgent(AGENT_ID, null, null); + assertTrue(result.contains("⚠️")); + assertTrue(result.contains("Message is required")); + } + + @Test + @DisplayName("Blank message rejected") + void converseWithAgent_blankMessage() { + String result = tool.converseWithAgent(AGENT_ID, " ", null); + assertTrue(result.contains("⚠️")); + } + + @Test + @DisplayName("Map-format output items extracted") + void converseWithAgent_mapFormatOutput() throws Exception { + when(conversationService.startConversation(any(Environment.class), eq(AGENT_ID), eq(USER_ID), anyMap())) + .thenReturn(new ConversationResult(CONVERSATION_ID, URI.create("/conv"))); + + var snapshot = new SimpleConversationMemorySnapshot(); + snapshot.setConversationId(CONVERSATION_ID); + snapshot.setConversationState(ConversationState.READY); + var output = new ConversationOutput(); + output.put("output", List.of(Map.of("text", "Map text"))); + snapshot.setConversationOutputs(List.of(output)); + stubSayWithSnapshot(snapshot); + + String result = tool.converseWithAgent(AGENT_ID, "Hello", null); + assertTrue(result.contains("Map text")); + } + + @Test + @DisplayName("Non-string/non-map items in output list are skipped") + void converseWithAgent_mixedOutputItems() throws Exception { + when(conversationService.startConversation(any(Environment.class), eq(AGENT_ID), eq(USER_ID), anyMap())) + .thenReturn(new ConversationResult(CONVERSATION_ID, URI.create("/conv"))); + + var snapshot = new SimpleConversationMemorySnapshot(); + snapshot.setConversationId(CONVERSATION_ID); + snapshot.setConversationState(ConversationState.READY); + var output = new ConversationOutput(); + output.put("output", List.of("text1", 42, Map.of("text", "text2"), Map.of("other", "no-text-key"))); + snapshot.setConversationOutputs(List.of(output)); + stubSayWithSnapshot(snapshot); + + String result = tool.converseWithAgent(AGENT_ID, "Hello", null); + assertTrue(result.contains("text1")); + assertTrue(result.contains("text2")); + assertFalse(result.contains("42")); + } + + @Test + @DisplayName("Null snapshot returns no response") + void converseWithAgent_nullSnapshot() throws Exception { + when(conversationService.startConversation(any(Environment.class), eq(AGENT_ID), eq(USER_ID), anyMap())) + .thenReturn(new ConversationResult(CONVERSATION_ID, URI.create("/conv"))); + + doAnswer(invocation -> { + ConversationResponseHandler handler = invocation.getArgument(8); + handler.onComplete(null); + return null; + }).when(conversationService).say( + any(), any(), any(), anyBoolean(), anyBoolean(), any(), any(), anyBoolean(), any()); + + String result = tool.converseWithAgent(AGENT_ID, "Hello", null); + assertTrue(result.contains("[no response]")); + } + + @Test + @DisplayName("Output with no 'output' key returns no response") + void converseWithAgent_noOutputKey() throws Exception { + when(conversationService.startConversation(any(Environment.class), eq(AGENT_ID), eq(USER_ID), anyMap())) + .thenReturn(new ConversationResult(CONVERSATION_ID, URI.create("/conv"))); + + var snapshot = new SimpleConversationMemorySnapshot(); + snapshot.setConversationId(CONVERSATION_ID); + snapshot.setConversationState(ConversationState.READY); + var output = new ConversationOutput(); + output.put("someOtherKey", "value"); + snapshot.setConversationOutputs(List.of(output)); + stubSayWithSnapshot(snapshot); + + String result = tool.converseWithAgent(AGENT_ID, "Hello", null); + assertTrue(result.contains("[no response]")); + } + + @Test + @DisplayName("General exception during say returns error") + void converseWithAgent_generalException() throws Exception { + when(conversationService.startConversation(any(Environment.class), eq(AGENT_ID), eq(USER_ID), anyMap())) + .thenReturn(new ConversationResult(CONVERSATION_ID, URI.create("/conv"))); + + doThrow(new RuntimeException("Connection lost")).when(conversationService).say( + any(), any(), any(), anyBoolean(), anyBoolean(), any(), any(), anyBoolean(), any()); + + String result = tool.converseWithAgent(AGENT_ID, "Hello", null); + assertTrue(result.contains("❌")); + assertTrue(result.contains("Connection lost")); + } + + @Test + @DisplayName("startConversation failure returns error message") + void converseWithAgent_startConversationFails_returnsError() throws Exception { + // Arrange — startConversation throws + when(conversationService.startConversation(any(Environment.class), eq(AGENT_ID), eq(USER_ID), anyMap())) + .thenThrow(new RuntimeException("Agent not deployed")); + + // Act + String result = tool.converseWithAgent(AGENT_ID, "Hello", null); + + // Assert + assertTrue(result.contains("❌"), "Should contain error marker"); + assertTrue(result.contains("Agent not deployed"), "Should contain the exception message"); + assertTrue(result.contains(AGENT_ID), "Should mention the agent ID"); + + // say() should never be called since startConversation failed + verify(conversationService, never()).say( + any(Environment.class), any(), any(), anyBoolean(), anyBoolean(), any(), any(), anyBoolean(), any()); + } + + @Test + @DisplayName("ERROR conversation state returns error message") + void converseWithAgent_errorState_returnsErrorMessage() throws Exception { + // Arrange — provide existing conversation ID, snapshot with ERROR state and no + // output + var snapshot = new SimpleConversationMemorySnapshot(); + snapshot.setConversationId(CONVERSATION_ID); + snapshot.setConversationState(ConversationState.ERROR); + snapshot.setConversationOutputs(new LinkedList<>()); // empty outputs + + stubSayWithSnapshot(snapshot); + + // Act + String result = tool.converseWithAgent(AGENT_ID, "Hello", CONVERSATION_ID); + + // Assert + assertTrue(result.contains("✅"), "Should contain success marker (tool itself succeeded)"); + assertTrue(result.contains("ERROR state") || result.contains("failed to produce output"), + "Should indicate the agent entered ERROR state"); + } + + @Test + @DisplayName("Empty response (no output in snapshot) is handled gracefully") + void converseWithAgent_emptyResponse_handledGracefully() throws Exception { + // Arrange — snapshot with READY state but no conversation outputs + var snapshot = new SimpleConversationMemorySnapshot(); + snapshot.setConversationId(CONVERSATION_ID); + snapshot.setConversationState(ConversationState.READY); + snapshot.setConversationOutputs(new LinkedList<>()); // no outputs + + stubSayWithSnapshot(snapshot); + + // Act + String result = tool.converseWithAgent(AGENT_ID, "Hello", CONVERSATION_ID); + + // Assert — extractResponse returns null for empty outputs (C5 fix), + // so the tool correctly shows "[no response]" + assertTrue(result.contains("✅"), "Should contain success marker"); + assertTrue(result.contains(CONVERSATION_ID), "Should contain conversation ID"); + assertTrue(result.contains("[no response]"), + "Null response from extractResponse should trigger [no response] placeholder"); + } + } + + // === FindAgentsByCapabilityTool === + + @Nested + class FindAgentsByCapabilityToolTest { + + private CapabilityRegistryService registryService; + private FindAgentsByCapabilityTool tool; + + @BeforeEach + void setUp() { + registryService = mock(CapabilityRegistryService.class); + tool = new FindAgentsByCapabilityTool(registryService); + } + + @Test + void findAgentsByCapability_found() { + when(registryService.findBySkill("translation", "highest_confidence")) + .thenReturn(List.of( + new CapabilityMatch("agent-1", "translation", "high", Map.of("languages", "de,en")), + new CapabilityMatch("agent-2", "translation", "medium", Map.of()))); + + String result = tool.findAgentsByCapability("translation", null); + + assertTrue(result.contains("🔍")); + assertTrue(result.contains("2 agent(s)")); + assertTrue(result.contains("agent-1")); + assertTrue(result.contains("agent-2")); + assertTrue(result.contains("languages=de,en")); + } + + @Test + void findAgentsByCapability_noneFound() { + when(registryService.findBySkill("nonexistent", "highest_confidence")) + .thenReturn(List.of()); + + String result = tool.findAgentsByCapability("nonexistent", null); + + assertTrue(result.contains("No agents found")); + } + + @Test + void findAgentsByCapability_skillRequired() { + String result = tool.findAgentsByCapability(null, null); + assertTrue(result.contains("⚠️")); + } + + @Test + void findAgentsByCapability_blankSkill() { + String result = tool.findAgentsByCapability(" ", null); + assertTrue(result.contains("⚠️")); + } + + @Test + void findAgentsByCapability_nullResult() { + when(registryService.findBySkill("test", "highest_confidence")).thenReturn(null); + + String result = tool.findAgentsByCapability("test", null); + assertTrue(result.contains("No agents found")); + } + + @Test + void findAgentsByCapability_noConfidence() { + when(registryService.findBySkill("test", "highest_confidence")) + .thenReturn(List.of(new CapabilityMatch("agent-1", "test", null, Map.of()))); + + String result = tool.findAgentsByCapability("test", null); + assertTrue(result.contains("agent-1")); + assertFalse(result.contains("Confidence:")); + } + + @Test + void findAgentsByCapability_blankConfidence() { + when(registryService.findBySkill("test", "highest_confidence")) + .thenReturn(List.of(new CapabilityMatch("agent-1", "test", " ", Map.of()))); + + String result = tool.findAgentsByCapability("test", null); + assertFalse(result.contains("Confidence:")); + } + + @Test + void findAgentsByCapability_nullAttributes() { + when(registryService.findBySkill("test", "highest_confidence")) + .thenReturn(List.of(new CapabilityMatch("agent-1", "test", "high", null))); + + String result = tool.findAgentsByCapability("test", null); + assertFalse(result.contains("Attributes:")); + } + + @Test + void findAgentsByCapability_exception() { + when(registryService.findBySkill("test", "highest_confidence")) + .thenThrow(new RuntimeException("Registry down")); + + String result = tool.findAgentsByCapability("test", null); + assertTrue(result.contains("❌")); + assertTrue(result.contains("Registry down")); + } + + @Test + void findAgentsByCapability_customStrategy() { + when(registryService.findBySkill("code-review", "round_robin")) + .thenReturn(List.of(new CapabilityMatch("agent-1", "code-review", "high", Map.of()))); + + String result = tool.findAgentsByCapability("code-review", "round_robin"); + + assertTrue(result.contains("round_robin")); + assertTrue(result.contains("agent-1")); + } + } + + // === TeardownAgentTool === + + @Nested + class TeardownAgentToolTest { + + private IAgentFactory agentFactory; + private IAgentStore agentStore; + private List createdAgentIds; + private Set retainedAgentIds; + private TeardownAgentTool tool; + + @BeforeEach + void setUp() { + agentFactory = mock(IAgentFactory.class); + agentStore = mock(IAgentStore.class); + createdAgentIds = new CopyOnWriteArrayList<>(List.of("created-1", "created-2")); + retainedAgentIds = ConcurrentHashMap.newKeySet(); + tool = new TeardownAgentTool(agentFactory, agentStore, createdAgentIds, retainedAgentIds); + } + + @Test + void teardownAgent_undeploy() throws Exception { + String result = tool.teardownAgent("created-1", false); + + assertTrue(result.contains("✅")); + assertTrue(result.contains("undeployed")); + verify(agentFactory).undeployAgent(any(Environment.class), eq("created-1"), isNull()); + verify(agentStore, never()).deleteAllPermanently(any()); + } + + @Test + void teardownAgent_undeployAndDelete() throws Exception { + String result = tool.teardownAgent("created-1", true); + + assertTrue(result.contains("✅")); + assertTrue(result.contains("deleted")); + verify(agentFactory).undeployAgent(any(Environment.class), eq("created-1"), isNull()); + verify(agentStore).deleteAllPermanently("created-1"); + } + + @Test + void teardownAgent_notCreatedByUs() { + String result = tool.teardownAgent("external-agent", false); + + assertTrue(result.contains("⚠️")); + assertTrue(result.contains("not created")); + verifyNoInteractions(agentFactory); + } + + @Test + void teardownAgent_retained() { + retainedAgentIds.add("created-1"); + + String result = tool.teardownAgent("created-1", false); + + assertTrue(result.contains("⚠️")); + assertTrue(result.contains("retained")); + verifyNoInteractions(agentFactory); + } + + @Test + void retainAgent_success() { + String result = tool.retainAgent("created-1"); + + assertTrue(result.contains("✅")); + assertTrue(result.contains("retained")); + assertTrue(retainedAgentIds.contains("created-1")); + } + + @Test + void retainAgent_notCreatedByUs() { + String result = tool.retainAgent("external-agent"); + + assertTrue(result.contains("⚠️")); + assertTrue(result.contains("not created")); + assertFalse(retainedAgentIds.contains("external-agent")); + } + + @Test + void retainAgent_alreadyRetained() { + retainedAgentIds.add("created-1"); + + String result = tool.retainAgent("created-1"); + + assertTrue(result.contains("already")); + } + + @Test + void teardownAgent_agentIdRequired() { + String result = tool.teardownAgent(null, false); + assertTrue(result.contains("⚠️")); + } + + @Test + void teardownAgent_undeployFailure() throws Exception { + doThrow(new RuntimeException("Agent not found")).when(agentFactory) + .undeployAgent(any(Environment.class), eq("created-1"), isNull()); + + String result = tool.teardownAgent("created-1", false); + + assertTrue(result.contains("❌")); + assertTrue(result.contains("Agent not found")); + } + + @Test + void teardownAgent_deleteFailure() throws Exception { + doThrow(new RuntimeException("DB locked")).when(agentStore) + .deleteAllPermanently("created-1"); + + String result = tool.teardownAgent("created-1", true); + + assertTrue(result.contains("⚠️")); + assertTrue(result.contains("undeployed")); + assertTrue(result.contains("deletion failed")); + } + + @Test + void unretainAgent_success() { + retainedAgentIds.add("created-1"); + + String result = tool.unretainAgent("created-1"); + + assertTrue(result.contains("✅")); + assertTrue(result.contains("Retention flag removed")); + assertFalse(retainedAgentIds.contains("created-1")); + } + + @Test + void unretainAgent_notRetained() { + String result = tool.unretainAgent("created-1"); + + assertTrue(result.contains("⚠️")); + assertTrue(result.contains("not currently retained")); + } + + @Test + void unretainAgent_agentIdRequired() { + String result = tool.unretainAgent(null); + assertTrue(result.contains("⚠️")); + } + + @Test + void constructor_nullArgs_doesNotThrow() { + // Null constructor args should produce safe fallback collections + var safeTool = new TeardownAgentTool(agentFactory, agentStore, null, null); + // teardownAgent with unknown agentId returns a warning (doesn't NPE) + String result = safeTool.teardownAgent("non-existent", false); + assertTrue(result.contains("⚠️")); + // unretainAgent also doesn't throw (exercises retainedAgentIds fallback) + String unretainResult = safeTool.unretainAgent("non-existent"); + assertTrue(unretainResult.contains("⚠️") || unretainResult.contains("not marked")); + } + } + + // === DynamicAgentConfig === + + @Nested + class DynamicAgentConfigTest { + + @Test + void defaults() { + var config = new DynamicAgentConfig(); + + assertFalse(config.isEnabled()); + assertFalse(config.isAllowCreation()); + assertFalse(config.isAllowRecruitment()); + assertTrue(config.isAllowDelegation()); + assertEquals(5, config.getMaxCreatedAgentsPerDiscussion()); + assertEquals(10, config.getMaxRecruitedAgentsPerDiscussion()); + assertEquals(3, config.getMaxDelegationsPerTask()); + assertTrue(config.isInheritParentModel()); + assertEquals(AgentGroupConfiguration.LifecyclePolicy.EPHEMERAL, config.getLifecyclePolicy()); + assertNull(config.getAllowedProviders()); + assertNull(config.getAllowedModels()); + } + + @Test + void setAndGet() { + var config = new DynamicAgentConfig(); + config.setEnabled(true); + config.setAllowCreation(true); + config.setAllowRecruitment(true); + config.setAllowDelegation(false); + config.setMaxCreatedAgentsPerDiscussion(3); + config.setMaxRecruitedAgentsPerDiscussion(7); + config.setMaxDelegationsPerTask(2); + config.setAllowedProviders(List.of("openai", "anthropic")); + config.setAllowedModels(Map.of("openai", List.of("gpt-4o"))); + config.setInheritParentModel(false); + config.setLifecyclePolicy(AgentGroupConfiguration.LifecyclePolicy.AGENT_DECIDES); + + assertTrue(config.isEnabled()); + assertTrue(config.isAllowCreation()); + assertTrue(config.isAllowRecruitment()); + assertFalse(config.isAllowDelegation()); + assertEquals(3, config.getMaxCreatedAgentsPerDiscussion()); + assertEquals(7, config.getMaxRecruitedAgentsPerDiscussion()); + assertEquals(2, config.getMaxDelegationsPerTask()); + assertEquals(List.of("openai", "anthropic"), config.getAllowedProviders()); + assertEquals(Map.of("openai", List.of("gpt-4o")), config.getAllowedModels()); + assertFalse(config.isInheritParentModel()); + assertEquals(AgentGroupConfiguration.LifecyclePolicy.AGENT_DECIDES, config.getLifecyclePolicy()); + } + + @Test + void lifecyclePolicyNullSafe() { + var config = new DynamicAgentConfig(); + config.setLifecyclePolicy(null); + // null should be coerced to EPHEMERAL + assertEquals(AgentGroupConfiguration.LifecyclePolicy.EPHEMERAL, config.getLifecyclePolicy()); + } + } + + // === GroupConversation dynamic member fields === + + @Nested + class GroupConversationDynamicFieldsTest { + + @Test + void addDynamicMember_threadSafe() { + var gc = new ai.labs.eddi.configs.groups.model.GroupConversation(); + var member = new ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.GroupMember( + "dynamic-1", "Dynamic Agent", 99, "specialist"); + + gc.addDynamicMember(member); + + assertEquals(1, gc.getDynamicMembers().size()); + assertEquals("dynamic-1", gc.getDynamicMembers().get(0).agentId()); + } + + @Test + void createdAgentIds_tracking() { + var gc = new ai.labs.eddi.configs.groups.model.GroupConversation(); + + gc.getCreatedAgentIds().add("agent-a"); + gc.getCreatedAgentIds().add("agent-b"); + + assertEquals(2, gc.getCreatedAgentIds().size()); + assertTrue(gc.getCreatedAgentIds().contains("agent-a")); + } + + @Test + void retainedAgentIds_tracking() { + var gc = new ai.labs.eddi.configs.groups.model.GroupConversation(); + + gc.getRetainedAgentIds().add("agent-a"); + + assertTrue(gc.getRetainedAgentIds().contains("agent-a")); + assertFalse(gc.getRetainedAgentIds().contains("agent-b")); + } + + @Test + void setDynamicMembers_null_safe() { + var gc = new ai.labs.eddi.configs.groups.model.GroupConversation(); + gc.setDynamicMembers(null); + assertNotNull(gc.getDynamicMembers()); + assertTrue(gc.getDynamicMembers().isEmpty()); + } + + @Test + void setCreatedAgentIds_null_safe() { + var gc = new ai.labs.eddi.configs.groups.model.GroupConversation(); + gc.setCreatedAgentIds(null); + assertNotNull(gc.getCreatedAgentIds()); + assertTrue(gc.getCreatedAgentIds().isEmpty()); + } + + @Test + void setRetainedAgentIds_null_safe() { + var gc = new ai.labs.eddi.configs.groups.model.GroupConversation(); + gc.setRetainedAgentIds(null); + assertNotNull(gc.getRetainedAgentIds()); + assertTrue(gc.getRetainedAgentIds().isEmpty()); + } + } +}