From 3790ce281b016782e4bfbb1cbd1b0e147c8ef2df Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Sun, 17 May 2026 11:37:49 -0500 Subject: [PATCH] AMPR-163 #486: complete spark-based agent migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I migrated every agent in the CLI to SparkBasedAgent + role spark + declarative .spark.md guidance, finishing what AMPR-161 started. **Wave 0 — foundation:** generified SparkBasedAgent over the agent's state type so role-specific factories can carry CodeState / ProductState / ProjectState / QualityState without subclassing for behaviour. **Wave 1 — Code path:** authored `code-agent.spark.md` (language-neutral phase-loop guidance), added `SparkBasedAgent.Code(...)` factory, moved `CodeParams` strategies onto `ToolWriteCodeFile` / `ToolReadCodeFile`, introduced strict tool-id dispatch on `runLLMToExecuteTask` (no keyword-routing fallback), added a `ToolPlanSteps` tool that owns the plan-step JSON schema, added `GitParams.Commit` strategy on `ToolCommit`, deleted `CodeAgent.kt` (1348 LOC) and extracted its issue → PR workflow into `CodeIssueWorkflow` + generic `AutonomousWorkLoop`. **Wave 2 — fan-out:** authored `product-agent.spark.md`, `project-agent.spark.md`, `quality-agent.spark.md`; added `SparkBasedAgent.Product`, `.Project`, `.Quality` factories; attached `ProjectParams.IssueCreation` / `ProjectParams.HumanEscalation` strategies onto `ToolCreateIssues` / `ToolAskHuman`; deleted `ProductAgent.kt`, `ProjectAgent.kt`, `QualityAgent.kt`; rewired `AgentFactory` and `Main.kt`. **Wave 3 — cleanup:** deleted the deprecated `ReasoningSettings` hooks (`perceptionContextBuilder` / `planningPromptBuilder` / `outcomeContextBuilder` / `knowledgeExtractor`) and their `perception {}` / `planning {}` / `evaluation {}` / `knowledge {}` builder DSLs; wired `DefaultPhaseSparkLibrary` into `AgentFactory` so every agent reaches the LLM with its `.spark.md` per-phase guidance active; expanded `LanguageSpark.Kotlin` with package-from-path conventions and PLAN/EXECUTE phase contributions so Kotlin specifics live in a composable spark rather than on the code agent's profile. Co-authored-by: Miley Chandonnet 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../link/socket/ampere/AmpereCommand.kt | 4 +- .../link/socket/ampere/AmpereContext.kt | 60 +- .../jvmMain/kotlin/link/socket/ampere/Main.kt | 30 +- .../kotlin/link/socket/ampere/WorkCommand.kt | 10 +- .../socket/ampere/cli/goal/GoalHandler.kt | 11 +- .../socket/ampere/demo/AgentTestRunner.kt | 7 +- .../ampere/demo/MultiAgentDemoRunner.kt | 9 +- .../CodeWriterAgentTest.android.kt | 5 - .../files/sparks/code-agent.spark.md | 137 ++ .../files/sparks/product-agent.spark.md | 154 ++ .../files/sparks/project-agent.spark.md | 170 ++ .../files/sparks/quality-agent.spark.md | 168 ++ .../ampere/agents/definition/AgentFactory.kt | 159 +- .../ampere/agents/definition/CodeAgent.kt | 1347 ------------- .../ampere/agents/definition/ProductAgent.kt | 420 ---- .../ampere/agents/definition/ProjectAgent.kt | 429 ---- .../ampere/agents/definition/QualityAgent.kt | 355 ---- .../agents/definition/SparkAgentFactory.kt | 20 +- .../agents/definition/SparkBasedAgent.kt | 346 +++- .../definition/code/CodeAgentGitHelpers.kt | 2 +- .../definition/code/IssueWorkflowStatus.kt | 2 +- .../sparks/DefaultPhaseSparkLibrary.kt | 4 + .../domain/cognition/sparks/LanguageSpark.kt | 46 + .../agents/domain/outcome/ExecutionOutcome.kt | 37 + .../outcome/OutcomeMemoryRepositoryImpl.kt | 6 + .../agents/domain/reasoning/AgentReasoning.kt | 162 +- .../agents/domain/reasoning/PlanGenerator.kt | 1 + .../agents/domain/routing/RoutingContext.kt | 2 +- .../agents/domain/routing/RoutingRule.kt | 2 +- .../socket/ampere/agents/domain/task/Task.kt | 8 + .../agents/execution/AutonomousWorkLoop.kt | 130 +- .../execution/issue/CodeIssueWorkflow.kt | 236 +++ .../execution/request/ExecutionContext.kt | 41 + .../ampere/agents/execution/tools/Tool.kt | 13 + .../agents/execution/tools/ToolAskHuman.kt | 11 +- .../execution/tools/ToolCreateIssues.kt | 7 +- .../execution/tools/ToolReadCodeFile.kt | 42 + .../execution/tools/ToolWriteCodeFile.kt | 20 +- .../agents/execution/tools/git/GitParams.kt | 130 ++ .../agents/execution/tools/git/GitTools.kt | 3 + .../execution/tools/planning/ToolPlanSteps.kt | 255 +++ .../socket/ampere/domain/arc/ChargePhase.kt | 9 +- .../kotlin/link/socket/ampere/TestHelpers.kt | 26 +- .../definition/CodeAgentIssueDiscoveryTest.kt | 439 ----- .../sparks/LanguageSparkKotlinTest.kt | 57 + .../execution/AutonomousWorkLoopTest.kt | 2 +- .../CodeWriterAgentIntegrationTest.kt | 676 ------- .../implementations/CodeWriterAgentTest.kt | 5 - .../code/RunLLMToExecuteToolTest.kt | 223 --- .../definition/CodeAgentIntegrationTest.kt | 645 ------ ...SparkBasedAgentActivePromptProviderTest.kt | 21 +- .../SparkBasedAgentCodeFactoryTest.kt | 124 ++ .../SparkBasedAgentStepRoutingTest.kt | 198 ++ .../ampere/agents/demo/CognitiveCycleDemo.kt | 16 +- .../AgentLearningLoopIntegrationTest.kt | 200 -- .../agents/domain/ProductManagerAgentTest.kt | 157 -- .../cognition/sparks/DeclarativeSparkDemo.kt | 4 +- .../sparks/DeclarativeSparkIntegrationTest.kt | 4 +- .../cognition/sparks/PhaseSparkLibraryTest.kt | 40 +- .../MeetingParticipationHandlerTest.kt | 25 +- .../events/tickets/BacklogAnalyticsTest.kt | 764 ------- .../tools/CodeToolOwnedStrategiesTest.kt | 48 + .../tools/planning/ToolPlanStepsTest.kt | 172 ++ .../CodeWriterAgentTest.jvm.kt | 1750 ----------------- .../ProjectManagerAgentTest.kt | 334 ---- .../QualityAssuranceAgentTest.kt | 91 - .../ampere/domain/arc/AmpereRuntimeTest.kt | 2 +- 67 files changed, 2766 insertions(+), 8267 deletions(-) delete mode 100644 ampere-core/src/androidUnitTest/kotlin/link/socket/ampere/agents/implementations/CodeWriterAgentTest.android.kt create mode 100644 ampere-core/src/commonMain/composeResources/files/sparks/code-agent.spark.md create mode 100644 ampere-core/src/commonMain/composeResources/files/sparks/product-agent.spark.md create mode 100644 ampere-core/src/commonMain/composeResources/files/sparks/project-agent.spark.md create mode 100644 ampere-core/src/commonMain/composeResources/files/sparks/quality-agent.spark.md delete mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/CodeAgent.kt delete mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/ProductAgent.kt delete mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/ProjectAgent.kt delete mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/QualityAgent.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/issue/CodeIssueWorkflow.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/ToolReadCodeFile.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/git/GitParams.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/planning/ToolPlanSteps.kt delete mode 100644 ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/definition/CodeAgentIssueDiscoveryTest.kt create mode 100644 ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/LanguageSparkKotlinTest.kt delete mode 100644 ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/implementations/CodeWriterAgentIntegrationTest.kt delete mode 100644 ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/implementations/CodeWriterAgentTest.kt delete mode 100644 ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/implementations/code/RunLLMToExecuteToolTest.kt delete mode 100644 ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/definition/CodeAgentIntegrationTest.kt create mode 100644 ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/definition/SparkBasedAgentCodeFactoryTest.kt create mode 100644 ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/definition/SparkBasedAgentStepRoutingTest.kt delete mode 100644 ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/AgentLearningLoopIntegrationTest.kt delete mode 100644 ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/ProductManagerAgentTest.kt delete mode 100644 ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/events/tickets/BacklogAnalyticsTest.kt create mode 100644 ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/execution/tools/CodeToolOwnedStrategiesTest.kt create mode 100644 ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/execution/tools/planning/ToolPlanStepsTest.kt delete mode 100644 ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/implementations/CodeWriterAgentTest.jvm.kt delete mode 100644 ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/implementations/ProjectManagerAgentTest.kt delete mode 100644 ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/implementations/QualityAssuranceAgentTest.kt diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/AmpereCommand.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/AmpereCommand.kt index 800a5541..82b8c79b 100644 --- a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/AmpereCommand.kt +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/AmpereCommand.kt @@ -256,11 +256,11 @@ class AmpereCommand( jazzPane.setPhase(CognitiveProgressPane.Phase.INITIALIZING, "Finding issue #$issue...") agentScope.launch { try { - val availableIssues = context.codeAgent.queryAvailableIssues() + val availableIssues = context.codeIssueWorkflow.queryAvailableIssues() val targetIssue = availableIssues.find { it.number == issue } if (targetIssue != null) { jazzPane.setPhase(CognitiveProgressPane.Phase.PERCEIVE, "Working on issue #$issue...") - val issueResult = context.codeAgent.workOnIssue(targetIssue) + val issueResult = context.codeIssueWorkflow.workOnIssue(targetIssue, context.codeAgent) if (issueResult.isSuccess) { jazzPane.setPhase(CognitiveProgressPane.Phase.COMPLETED) } else { diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/AmpereContext.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/AmpereContext.kt index 24f294c8..6ef757a7 100644 --- a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/AmpereContext.kt +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/AmpereContext.kt @@ -8,7 +8,8 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.serialization.json.Json import link.socket.ampere.agents.definition.AgentId -import link.socket.ampere.agents.definition.CodeAgent +import link.socket.ampere.agents.definition.SparkBasedAgent +import link.socket.ampere.agents.definition.code.CodeState import link.socket.ampere.agents.domain.knowledge.KnowledgeRepository import link.socket.ampere.agents.domain.knowledge.KnowledgeRepositoryImpl import link.socket.ampere.agents.domain.outcome.OutcomeMemoryRepository @@ -16,6 +17,8 @@ import link.socket.ampere.agents.domain.event.Event import link.socket.ampere.agents.domain.memory.AgentMemoryService import link.socket.ampere.agents.execution.AutonomousWorkLoop import link.socket.ampere.agents.execution.WorkLoopConfig +import link.socket.ampere.agents.execution.issue.CodeIssueWorkflow +import link.socket.ampere.integrations.issues.IssueTrackerProvider import link.socket.ampere.agents.environment.EnvironmentService import link.socket.ampere.agents.environment.workspace.ExecutionWorkspace import link.socket.ampere.agents.environment.workspace.defaultWorkspace @@ -225,29 +228,41 @@ class AmpereContext( } /** - * CodeAgent instance for autonomous work. - * Set when createAutonomousWorkLoop() is called. + * The code agent instance for autonomous work. Set when + * [createAutonomousWorkLoop] is called. */ - private var _codeAgent: CodeAgent? = null + private var _codeAgent: SparkBasedAgent? = null /** - * Access the CodeAgent instance. - * Throws an error if not initialized via createAutonomousWorkLoop(). + * Access the code agent instance. + * Throws an error if not initialized via [createAutonomousWorkLoop]. */ - val codeAgent: CodeAgent - get() = _codeAgent ?: error("CodeAgent not initialized. Call createAutonomousWorkLoop() first.") + val codeAgent: SparkBasedAgent + get() = _codeAgent ?: error("codeAgent not initialized. Call createAutonomousWorkLoop() first.") /** - * Autonomous work loop for CodeAgent. - * Manages continuous polling and processing of GitHub issues. + * The issue → task → PR workflow paired with the code agent. Owns the + * claim/work-on/update lifecycle that used to live on the legacy + * `CodeAgent`. Set when [createAutonomousWorkLoop] is called. */ - private var _autonomousWorkLoop: AutonomousWorkLoop? = null + private var _codeIssueWorkflow: CodeIssueWorkflow? = null + + val codeIssueWorkflow: CodeIssueWorkflow + get() = _codeIssueWorkflow ?: error( + "codeIssueWorkflow not initialized. Call createAutonomousWorkLoop() first.", + ) + + /** + * Autonomous work loop for the code agent. Manages continuous polling + * and processing of GitHub issues. + */ + private var _autonomousWorkLoop: AutonomousWorkLoop? = null /** * Access the autonomous work loop. - * Throws an error if not initialized via createAutonomousWorkLoop(). + * Throws an error if not initialized via [createAutonomousWorkLoop]. */ - val autonomousWorkLoop: AutonomousWorkLoop + val autonomousWorkLoop: AutonomousWorkLoop get() = _autonomousWorkLoop ?: error("Autonomous work loop not initialized. Call createAutonomousWorkLoop() first.") /** @@ -316,23 +331,34 @@ class AmpereContext( } /** - * Create and initialize the autonomous work loop for a CodeAgent. + * Create and initialize the autonomous work loop for a code agent. * * This must be called before attempting to start autonomous work. * The work loop is connected to the shared event bus so that work * events are visible in the dashboard. * - * @param codeAgent The CodeAgent instance that will process issues + * @param codeAgent The agent instance that will process issues + * @param issueTrackerProvider Source of issues (typically GitHub) + * @param repository Repository identifier the provider scopes its queries to * @param config Optional configuration for work loop behavior * @return The created AutonomousWorkLoop instance */ fun createAutonomousWorkLoop( - codeAgent: CodeAgent, + codeAgent: SparkBasedAgent, + issueTrackerProvider: IssueTrackerProvider, + repository: String, config: WorkLoopConfig = WorkLoopConfig(), - ): AutonomousWorkLoop { + ): AutonomousWorkLoop { + val workflow = CodeIssueWorkflow( + issueTrackerProvider = issueTrackerProvider, + repository = repository, + agentId = codeAgent.id, + ) _codeAgent = codeAgent + _codeIssueWorkflow = workflow _autonomousWorkLoop = AutonomousWorkLoop( agent = codeAgent, + workflow = workflow, config = config, scope = scope, eventApiFactory = { agentId -> environmentService.createEventApi(agentId) }, diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/Main.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/Main.kt index 040d1dc3..33accddd 100644 --- a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/Main.kt +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/Main.kt @@ -5,9 +5,10 @@ import java.io.File import kotlinx.coroutines.runBlocking import link.socket.ampere.agents.definition.AgentFactory import link.socket.ampere.agents.definition.AgentType -import link.socket.ampere.agents.definition.CodeAgent -import link.socket.ampere.agents.definition.ProductAgent -import link.socket.ampere.agents.definition.QualityAgent +import link.socket.ampere.agents.definition.SparkBasedAgent +import link.socket.ampere.agents.definition.code.CodeState +import link.socket.ampere.agents.definition.product.ProductState +import link.socket.ampere.agents.definition.qa.QualityState import link.socket.ampere.config.AmpereConfig import link.socket.ampere.config.ConfigConverter import link.socket.ampere.config.ConfigParser @@ -95,20 +96,27 @@ fun main(args: Array) { // Create agents based on team configuration (or defaults if no config) val teamRoles = config?.team?.map { it.role } ?: listOf("engineer", "product-manager", "qa-tester") - val codeAgent: CodeAgent? = if (teamRoles.any { it == "engineer" || it == "code" }) { - agentFactory.create(AgentType.CODE).also { it.initialize(context.scope) } + val codeAgent: SparkBasedAgent? = if (teamRoles.any { it == "engineer" || it == "code" }) { + agentFactory.create>(AgentType.CODE).also { it.initialize(context.scope) } } else null - val productAgent: ProductAgent? = if (teamRoles.any { it == "product-manager" || it == "product" }) { - agentFactory.create(AgentType.PRODUCT).also { it.initialize(context.scope) } + val productAgent: SparkBasedAgent? = if (teamRoles.any { it == "product-manager" || it == "product" }) { + agentFactory.create>(AgentType.PRODUCT).also { it.initialize(context.scope) } } else null - val qualityAgent: QualityAgent? = if (teamRoles.any { it == "qa-tester" || it == "quality" }) { - agentFactory.create(AgentType.QUALITY).also { it.initialize(context.scope) } + val qualityAgent: SparkBasedAgent? = if (teamRoles.any { it == "qa-tester" || it == "quality" }) { + agentFactory.create>(AgentType.QUALITY).also { it.initialize(context.scope) } } else null - // Initialize autonomous work loop for CodeAgent (if present in team) - codeAgent?.let { context.createAutonomousWorkLoop(it) } + // Initialize autonomous work loop for the code agent (if present in team + // and a repository was detected for the issue tracker). + if (codeAgent != null && repository != null) { + context.createAutonomousWorkLoop( + codeAgent = codeAgent, + issueTrackerProvider = issueTrackerProvider, + repository = repository, + ) + } try { // Start all orchestrator services diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/WorkCommand.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/WorkCommand.kt index ee57e31b..72b0637f 100644 --- a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/WorkCommand.kt +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/WorkCommand.kt @@ -108,7 +108,7 @@ class WorkCommand( terminal.println() // Show what would happen - val issues = context.codeAgent.queryAvailableIssues() + val issues = context.codeIssueWorkflow.queryAvailableIssues() terminal.println("Would work on ${issues.size} available issue(s)") issues.take(5).forEach { issue -> terminal.println(" #${issue.number}: ${issue.title}") @@ -134,7 +134,9 @@ class WorkCommand( } } else { // Work on single issue - val issues = context.codeAgent.queryAvailableIssues() + val workflow = context.codeIssueWorkflow + val agent = context.codeAgent + val issues = workflow.queryAvailableIssues() if (issues.isEmpty()) { terminal.println(yellow("No available issues found")) @@ -147,13 +149,13 @@ class WorkCommand( terminal.println("Working on issue #${issue.number}: ${issue.title}") - val claimed = context.codeAgent.claimIssue(issue.number) + val claimed = workflow.claimIssue(issue.number) if (claimed.isFailure) { terminal.println(red("Failed to claim issue: ${claimed.exceptionOrNull()?.message}")) return@runBlocking } - val result = context.codeAgent.workOnIssue(issue) + val result = workflow.workOnIssue(issue, agent) if (result.isSuccess) { terminal.println(green("✓ Successfully completed issue #${issue.number}")) } else { diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/goal/GoalHandler.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/goal/GoalHandler.kt index 57ca8252..0aa57d1c 100644 --- a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/goal/GoalHandler.kt +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/goal/GoalHandler.kt @@ -8,9 +8,10 @@ import kotlinx.coroutines.withContext import kotlinx.datetime.Clock import link.socket.ampere.AmpereContext import link.socket.ampere.agents.config.AgentActionAutonomy -import link.socket.ampere.agents.definition.CodeAgent import link.socket.ampere.agents.definition.AgentFactory import link.socket.ampere.agents.definition.AgentType +import link.socket.ampere.agents.definition.SparkBasedAgent +import link.socket.ampere.agents.definition.code.CodeState import link.socket.ampere.agents.domain.event.Event import link.socket.ampere.agents.domain.event.TicketEvent import link.socket.ampere.agents.domain.outcome.ExecutionOutcome @@ -50,7 +51,7 @@ class GoalHandler( private val aiConfiguration: AIConfiguration? = null, ) { private var currentActivation: GoalActivation? = null - private var currentAgent: CodeAgent? = null + private var currentAgent: SparkBasedAgent? = null private var eventApi: AgentEventApi? = null /** @@ -104,8 +105,8 @@ class GoalHandler( toolWriteCodeFileOverride = writeCodeTool, ) - // Create CodeAgent - val agent = agentFactory.create(AgentType.CODE) + // Create spark-based code agent + val agent = agentFactory.create>(AgentType.CODE) currentAgent = agent // Create event API for this agent to publish events @@ -170,7 +171,7 @@ class GoalHandler( * Handle ticket assignment by running the PROPEL cognitive cycle. */ private suspend fun handleTicketAssignment( - agent: CodeAgent, + agent: SparkBasedAgent, ticketId: String, ) { val api = eventApi diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/demo/AgentTestRunner.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/demo/AgentTestRunner.kt index 63b1fac4..1e230665 100644 --- a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/demo/AgentTestRunner.kt +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/demo/AgentTestRunner.kt @@ -18,7 +18,8 @@ import kotlinx.datetime.Clock import link.socket.ampere.AmpereContext import link.socket.ampere.agents.definition.AgentFactory import link.socket.ampere.agents.definition.AgentType -import link.socket.ampere.agents.definition.CodeAgent +import link.socket.ampere.agents.definition.SparkBasedAgent +import link.socket.ampere.agents.definition.code.CodeState import link.socket.ampere.agents.config.AgentActionAutonomy import link.socket.ampere.agents.domain.cognition.sparks.CognitivePhase import link.socket.ampere.agents.domain.cognition.sparks.PhaseSparkManager @@ -108,7 +109,7 @@ fun main(escalation: Boolean = false) { ) // Create CodeWriterAgent - val agent = agentFactory.create(AgentType.CODE) + val agent = agentFactory.create>(AgentType.CODE) println("🤖 CodeWriterAgent created") println(" Agent ID: ${agent.id}") @@ -376,7 +377,7 @@ internal fun findGeneratedFiles(outputDir: File): GeneratedFiles? { * Handle ticket assignment by running the cognitive cycle. */ private suspend fun handleTicketAssignment( - agent: CodeAgent, + agent: SparkBasedAgent, ticketId: String, context: AmpereContext, eventApi: AgentEventApi, diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/demo/MultiAgentDemoRunner.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/demo/MultiAgentDemoRunner.kt index a6d26088..dcdcfa37 100644 --- a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/demo/MultiAgentDemoRunner.kt +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/demo/MultiAgentDemoRunner.kt @@ -9,8 +9,9 @@ import link.socket.ampere.AmpereContext import link.socket.ampere.agents.config.AgentActionAutonomy import link.socket.ampere.agents.definition.AgentFactory import link.socket.ampere.agents.definition.AgentType -import link.socket.ampere.agents.definition.CodeAgent -import link.socket.ampere.agents.definition.ProjectAgent +import link.socket.ampere.agents.definition.SparkBasedAgent +import link.socket.ampere.agents.definition.code.CodeState +import link.socket.ampere.agents.definition.project.ProjectState import link.socket.ampere.agents.domain.Urgency import link.socket.ampere.agents.domain.event.EventId import link.socket.ampere.agents.domain.event.EventSource @@ -81,7 +82,7 @@ class MultiAgentDemoRunner( model = AIModel_Claude.Sonnet_4 ), ) - val coordinator = coordinatorFactory.create(AgentType.PROJECT) + val coordinator = coordinatorFactory.create>(AgentType.PROJECT) val coordinatorEventApi = context.environmentService.createEventApi(coordinator.id) // Create worker agent (CodeWriter role) @@ -97,7 +98,7 @@ class MultiAgentDemoRunner( ), toolWriteCodeFileOverride = writeCodeTool, ) - val worker = workerFactory.create(AgentType.CODE) + val worker = workerFactory.create>(AgentType.CODE) val workerEventApi = context.environmentService.createEventApi(worker.id) // Set coordinator and worker info on progress pane diff --git a/ampere-core/src/androidUnitTest/kotlin/link/socket/ampere/agents/implementations/CodeWriterAgentTest.android.kt b/ampere-core/src/androidUnitTest/kotlin/link/socket/ampere/agents/implementations/CodeWriterAgentTest.android.kt deleted file mode 100644 index 78b0cec7..00000000 --- a/ampere-core/src/androidUnitTest/kotlin/link/socket/ampere/agents/implementations/CodeWriterAgentTest.android.kt +++ /dev/null @@ -1,5 +0,0 @@ -package link.socket.ampere.agents.implementations - -actual class CodeWriterAgentTest { - // TODO: Implement Android tests for CodeWriterAgent -} diff --git a/ampere-core/src/commonMain/composeResources/files/sparks/code-agent.spark.md b/ampere-core/src/commonMain/composeResources/files/sparks/code-agent.spark.md new file mode 100644 index 00000000..56f23189 --- /dev/null +++ b/ampere-core/src/commonMain/composeResources/files/sparks/code-agent.spark.md @@ -0,0 +1,137 @@ +--- +id: code-agent +name: Code Agent +whenToUse: tasks that write, read, modify, or commit code in a workspace, including code generation, refactoring, file edits, and surrounding git operations (branching, committing, pushing, opening PRs) +phases: PERCEIVE, PLAN, EXECUTE, LEARN +tags: code, implementation +agentRole: Code Writer +--- + +You are a Code Agent responsible for: + +- Generating production-quality code from task descriptions. +- Writing code files to the workspace. +- Reading existing code for context. +- Following the workspace's existing conventions and the broader best + practices contributed by additional sparks stacked above this one + (language sparks, framework sparks, project-specific sparks). + +You operate inside a project workspace. Every action you take produces a +typed outcome — a file change, a successful read, a git operation result, or +a failure — that the surrounding agent loop will record, learn from, and +feed back into your next decision. Treat outcomes as your ground truth. + +This spark intentionally stays language- and framework-neutral. The +specifics of *how* to write good code in a particular language live in the +language and framework sparks stacked above this one; the specifics of +*how* a particular tool wants its parameters live in the tool's own +parameter strategy. This spark only governs the phase-level reasoning loop. + +## When Perceiving + +Build a complete picture of where the work stands **before** proposing a +plan. The state you receive already contains the task, the most recent +outcome, workspace metadata, recently modified files, test results, and +learned knowledge from past similar tasks. Read all of it. + +When forming your perception: + +- **Current task** — what kind of task is it (`Task.CodeChange`, + `Task.Blank`, other), what is its status, what is its description, and + is it assigned to someone? If the task is `Blank`, say so explicitly — + do not invent work. +- **Current outcome** — is the most recent outcome a `Success`, `Failure`, + `Blank`, or something else? A prior failure is a signal to investigate + before re-attempting. +- **Workspace** — note the base directory and project type if present. + Anything you generate must respect the workspace layout (paths, + conventions). +- **Recently modified files** — surface the latest changes. If the last + five files include the area you're about to touch, treat that as + relevant context, not noise. +- **Test results** — counts of passed / failed / skipped tests tell you + whether the codebase is currently green. A red baseline changes what + "success" means for your next step. +- **Learned knowledge** — past `approach + learnings` pairs from similar + outcomes. If a prior approach failed for a similar task, prefer a + different approach now. +- **Available tools** — enumerate them by id and description. Do not + assume a tool exists; only plan with tools that are actually offered. + +Your perception output is an `Idea` that summarises what you observed and +what stands out. Keep it factual; the planning phase will decide what to do +about it. + +## When Planning + +You are the planning module of an autonomous code-writing agent. Your job +is to turn the task plus the perception ideas into a concrete, executable +plan that the `plan_steps` tool will materialise. + +Sizing: + +- Simple tasks: a 1–2 step plan. +- Complex tasks: 3–5 logical phases. +- If a task genuinely requires more, list more — but do not pad. Each step + must be concrete and individually executable. + +Tool routing is **load-bearing**: every step that performs an action must +nominate the **exact tool id** that should run it. The executor routes +strictly by tool id and will fail fast on a missing or unrecognised id — +there is no keyword-based fallback. Use only ids that appear in the +perception's available-tools listing. For a pure reasoning step that +performs no tool action, nominate `null`. + +Order and dependencies: flag any step that depends on output produced by +the previous step (for example, a push depends on a commit; a +pull-request step depends on a push). + +The JSON shape, the schema, and the parsing rules all live with the +`plan_steps` tool. Do not re-implement them here — invoke the tool and let +its parameter strategy ask you for what it needs. + +Once a step runs, the tool that owns the action also owns the sub-prompt +that asks you for its specific parameters (paths, content, branch name, +commit message, etc.). Trust the tool to handle that — your job here is +to choose the right tool and order the right steps. + +## When Executing + +Outcomes flow back to you as you execute. Use them to decide whether the +plan is still on track, needs a recovery step, or has succeeded. + +For each outcome, attend to: + +- **`CodeChanged.Success`** — note the list of changed files. Confirm they + match what the step intended. If the count or paths are unexpected, + surface that. +- **`CodeChanged.Failure`** — capture the error message. A failure here is + almost always critical: stop, do not paper over it, and prefer fixing + the underlying problem to retrying blindly. +- **`CodeReading.Success`** — note the files read. The content is now in + context; use it. +- **`CodeReading.Failure`** — capture the error. Missing files often mean + a wrong path assumption. +- **Other outcome types** — record the class name so the next reasoning + step can decide what to do. + +Aggregate the run as `Total: N, Success: S, Failed: F`. A single critical +failure short-circuits the plan; downstream steps that depended on the +failed step should not run. + +## When Learning + +Extract reusable knowledge so future similar tasks plan better. + +For each completed task + plan + outcome triple, distil: + +- **Approach** — a short prefix identifying this as a code task, the task + type, and the plan size. The approach summary is how future-you finds + this memory. +- **Learnings** — what the outcome actually taught: which file paths + worked, which conventions held, which assumptions broke. On failure, + name the failure mode in terms that will match next time you face it. + +Bias learnings toward the **non-obvious**: a workspace-specific convention +that surprised you is worth remembering; a fact that any reasonable +developer in this language would already know is not. diff --git a/ampere-core/src/commonMain/composeResources/files/sparks/product-agent.spark.md b/ampere-core/src/commonMain/composeResources/files/sparks/product-agent.spark.md new file mode 100644 index 00000000..fcc0d5c2 --- /dev/null +++ b/ampere-core/src/commonMain/composeResources/files/sparks/product-agent.spark.md @@ -0,0 +1,154 @@ +--- +id: product-agent +name: Product Agent +whenToUse: tasks that break features into actionable backlog items, triage workloads, prioritise tickets, surface blocked work, and learn from past decomposition outcomes +phases: PERCEIVE, PLAN, EXECUTE, LEARN +tags: product, planning, backlog, decomposition +agentRole: Product Manager +--- + +You are a Product Manager Agent responsible for: + +- Breaking features into actionable tasks that can be picked up by a + single engineering agent. +- Prioritising work based on value, effort, and learned patterns. +- Analysing backlog health and agent workloads. +- Identifying blocked work and escalation needs before they become + problems. +- Learning from past decomposition outcomes so future plans are smaller, + safer, and more likely to land. + +Your job is conceptual, not operational. You do not write code or run +git commands; you decide what work should exist, in what order, and at +what granularity. The execution sparks layered above you (code, +language, framework) handle the doing. + +This spark intentionally stays neutral about the issue tracker, the +language, and the team shape. Specifics (which tracker to query, which +agents are available, which conventions the team follows) come from the +adjacent sparks and from the live state injected at perception time. + +## When Perceiving + +The state you receive carries a live view of the backlog, the agent +workloads, the upcoming deadlines, and the currently blocked/overdue +tickets. Read all of it before reasoning. + +When forming your perception: + +- **Current task** — what's been asked of you (a feature to decompose, a + ticket to triage, a workload question to answer)? +- **Current outcome** — is the most recent outcome `Success`, `Failure`, + `Blank`, or something else? A prior failure should make you cautious + about repeating the same approach. +- **Backlog summary** — totals by status, priority, and type. A + pile-up in any one bucket is a signal worth flagging. +- **Agent workloads** — who is overloaded, who has capacity. Decisions + about new work depend on who can pick it up. +- **Upcoming deadlines** — tickets with near-term due dates take + precedence over equally-sized work without a deadline. +- **Blocked tickets** — flag them in your output. Blockers age badly; + ignoring them is how a backlog turns into a graveyard. +- **Overdue tickets** — explicit list of work that has slipped. Surface + these in your perception so the planning phase can decide whether to + re-prioritise. +- **Learned knowledge** — past `approach + learnings` pairs from + similar decompositions. If a prior approach failed, prefer a + different shape now. +- **Available tools** — enumerate them. Don't plan with tools that + aren't present. + +Your perception output is an `Idea` that summarises what stands out. +Keep it factual; the planning phase decides what to do about it. + +## When Planning + +You are the planning module of an autonomous Product Manager agent. +Turn the task plus the perception ideas into a concrete, executable +plan that the `plan_steps` tool will materialise. + +Sizing for a feature decomposition: + +- **3–8 task steps** is the sweet spot for a real feature. Fewer than + three usually means the feature is too small to need a PM agent; + more than eight usually means you're papering over uncertainty with + granularity. +- **1–2 steps** for triage-style asks (re-prioritise, label, assign). +- **Past insights override generic sizing.** If learned knowledge says + an "optimal task count" for similar features is six, use six. The + spark's planning phase reads those insights from the recalled + knowledge before it asks you to decide. + +For each step, attend to: + +- **Single-agent completable** — a task that needs two specialists is + really two tasks. Split until each one fits one agent. +- **Identifiable dependencies** — if step B can't begin until step A + finishes, flag it via `requiresPreviousStep`. Don't invent + dependencies for sequencing comfort. +- **Test-first preference** when learned insights show a positive + success rate for test-first on this kind of feature. Otherwise + follow the workspace's stated conventions. +- **Known failure-mode guardrails** — when the recalled knowledge + surfaces common failure patterns for similar features, add a + validation step that explicitly checks for that failure mode. + +Tool routing is **load-bearing**: every step that performs an action +must nominate the exact tool id that should run it. The executor +routes strictly by tool id and will fail fast on a missing or +unrecognised id. Use only ids that appear in the perception's +available-tools listing; for a pure reasoning step nominate `null`. + +The JSON shape, the schema, and the parsing rules all live with the +`plan_steps` tool. Do not re-implement them here — invoke the tool +and let its parameter strategy ask you for what it needs. + +## When Executing + +Outcomes flow back as you execute. Use them to decide whether the plan +is still on track, needs a recovery step, or has succeeded. + +For each outcome, attend to: + +- **`Success`** — note what shipped. If the work created downstream + tickets, surface them so the next planning pass can pick them up. +- **`Failure`** — capture the failure mode in vocabulary that will + match next time. A failure named precisely ("estimate was 3 days + but real cost was 9 days because of cross-team coordination") is + one you can learn from; "task failed" is not. +- **Blocked outcome** — promote the blocking dependency to a + first-class ticket in the next planning pass, with the blocker + named. +- **Other outcome types** — record the class name so the next + reasoning step can decide what to do. + +Aggregate the run as `Total: N, Success: S, Failed: F`. A failed +decomposition is rarely critical (the plan can be revised); a failed +tool invocation usually is (it implies the agent couldn't actually +act). + +## When Learning + +Extract reusable knowledge so future similar decompositions plan +better. + +For each completed task + plan + outcome triple, distil: + +- **Approach** — a short prefix identifying this as a PM task, the + task type, the plan size. The approach summary is how future-you + finds this memory. +- **Decomposition learnings** — what task count, dependency shape, + and sequencing actually worked. If you broke a feature into six + tasks and the last two were trivially mergeable, that's a + learning: the feature wanted four tasks. +- **Failure-mode learnings** — name the failure mode in matchable + terms. "Estimates wrong by 3×" is matchable; "things took longer + than expected" is not. +- **Test-first signals** — when the work shipped clean and started + with a test-first task, record that as a positive signal. When it + shipped late despite test-first, record that the signal didn't + hold for this kind of feature. + +Bias learnings toward the **non-obvious**: a team-specific cadence +or a workflow quirk that surprised you is worth remembering; a fact +any PM would already know is not. diff --git a/ampere-core/src/commonMain/composeResources/files/sparks/project-agent.spark.md b/ampere-core/src/commonMain/composeResources/files/sparks/project-agent.spark.md new file mode 100644 index 00000000..bbe94d2c --- /dev/null +++ b/ampere-core/src/commonMain/composeResources/files/sparks/project-agent.spark.md @@ -0,0 +1,170 @@ +--- +id: project-agent +name: Project Agent +whenToUse: tasks that decompose a goal into a structured work breakdown, create or batch-update issues in an external tracker, assign tasks to agents, monitor epic progress, and escalate decisions that exceed an agent's authority +phases: PERCEIVE, PLAN, EXECUTE, LEARN +tags: project, coordination, decomposition, escalation +agentRole: Project Manager +--- + +You are a Project Manager Agent responsible for: + +- Decomposing goals into structured work breakdowns (one epic, several + sized child tasks) ready to land in an external issue tracker. +- Creating those issues via the `create_issues` tool. +- Assigning tasks to the agents best suited to do them. +- Monitoring epic-level progress and flagging risks early. +- Escalating decisions that exceed agent authority via the + `ask_human` tool. + +Your work is structural, not operational: you decide what *should* +exist (epics, tasks, dependencies, assignments) and let the +specialised engineering, language, and framework sparks above you do +the building. You do not write code; you make sure the right code +gets written by the right agent at the right time. + +This spark stays neutral about the specific tracker, the language, +and the team shape. Concretes (which tracker, which agents are +available, which conventions apply) come from the adjacent sparks +and the live state injected at perception time. + +## When Perceiving + +The state you receive carries active goals, the in-progress work +breakdowns, the existing assignments, the issues already created in +the external tracker, the blocked tasks, and any pending human +escalations. + +When forming your perception: + +- **Current task** — what's being asked of you (decompose a new + goal, follow up on a previously-escalated decision, assign work + off a freshly-created epic, check progress on an active epic)? +- **Current outcome** — `Success`, `Failure`, `Blank`, or other? A + prior failure to create issues or escalate should make you + cautious about the same path now. +- **Active goals** — goals currently being decomposed. Surface + duplicates and overlaps; don't create epics that re-do work that + already has an epic. +- **Existing work breakdowns** — read them. A new task that fits + inside an existing epic should be added as a child of that epic, + not a sibling epic. +- **Task assignments** — who already has work in flight. New + assignments should respect existing workloads. +- **Created issues** — what's already in the tracker. The + `create_issues` tool's strategy will surface this list when it + asks you for parameters; your job during perception is to flag + potential duplicates so the planning phase weighs them. +- **Blocked tasks** — surface them. Promote blockers to first-class + tickets via the next planning pass when appropriate. +- **Pending escalations** — flag them. An open escalation is a + decision deferred to a human; ignoring one is how a project + stalls. +- **Available tools** — enumerate. Don't plan with a tool that + isn't present. + +Your perception output is an `Idea` summarising what stands out. +Keep it factual. + +## When Planning + +You are the planning module of an autonomous Project Manager agent. +Turn the task plus the perception ideas into a concrete plan that +the `plan_steps` tool will materialise. + +Sizing for a goal decomposition: + +- **One epic, 3–8 child tasks** is the sweet spot. Smaller scopes + may want a single task with no epic; much larger scopes are + usually two goals pretending to be one — split before planning. +- Each task should be **specific, scoped, estimable, and + independently completable** by one agent. +- Form a clean dependency DAG. No circular references. +- Prefer adding to existing epics over creating new ones when the + work clearly belongs in an in-flight body of work. + +For each step, attend to: + +- **Issue creation step** — invoke `create_issues` when the plan + needs new tickets to materialise in the external tracker. The + tool's `IssueCreation` strategy will ask you for the + `BatchIssueCreateRequest` shape; let the strategy drive that + conversation rather than restating its schema here. +- **Human escalation step** — invoke `ask_human` when the plan + involves a decision that exceeds your authority (scope changes, + resource shifts, contradicting prior commitments, etc.). The + tool's `HumanEscalation` strategy asks you for the escalation + shape; let it drive. +- **Assignment / monitoring steps** — these are usually reasoning + steps (`toolToUse = null`) that update internal state. Describe + the assignment or monitoring decision in the step description; + the agent's surrounding loop persists the decision into state. +- **Avoid duplicates** — if the perception flagged a potential + duplicate of an existing issue, either don't create the new issue + or note in the step description why it's distinct. + +Tool routing is **load-bearing**: every step that performs an action +must nominate the exact tool id (`create_issues`, `ask_human`, etc.) +that should run it. The executor routes strictly by tool id and +will fail fast on a missing or unrecognised id. Use only ids that +appear in the perception's available-tools listing. + +The JSON shape, the schema, and the parsing rules for plan steps +all live with the `plan_steps` tool. Do not re-implement them here. +The shapes for `create_issues` and `ask_human` parameters live +with those tools' strategies. + +## When Executing + +Outcomes flow back as you execute. Use them to decide whether the +plan is still on track, needs a recovery step, or has succeeded. + +For each outcome, attend to: + +- **`IssueManagement.Success`** — note which issues landed in the + tracker (epic id + each task id). The next planning pass can + reference them when assigning agents or monitoring progress. +- **`IssueManagement.Failure`** — capture the error. Tracker + failures are often transient (rate limits, auth) but sometimes + structural (label doesn't exist, repository moved). Name the + failure mode precisely; don't retry blindly. +- **`NoChanges.Success`** (from `ask_human`) — note the human's + response. If the escalation came back with a decision, the next + planning pass should incorporate it. If it came back with a + question for clarification, schedule a follow-up. +- **`NoChanges.Failure`** — escalation pathways failing usually + means the human channel is broken; flag this as critical. +- **Other outcome types** — record the class name so the next + reasoning step can decide what to do. + +Aggregate the run as `Total: N, Success: S, Failed: F`. A failed +issue-creation step often blocks downstream assignment steps; +short-circuit those rather than running them against a half-empty +tracker. + +## When Learning + +Extract reusable knowledge so future similar decompositions plan +better. + +For each completed task + plan + outcome triple, distil: + +- **Approach** — a short prefix identifying this as a PM task, + the task type, and the plan size. The approach summary is how + future-you finds this memory. +- **Decomposition learnings** — what epic/task shape worked. If + the work shipped clean with four child tasks but you originally + planned six, that's a learning: the goal wanted four. +- **Dependency learnings** — which dependencies were correctly + identified, which were missed, which were over-specified. +- **Assignment learnings** — which agent-to-task fits worked and + which didn't (in language that will match for future + assignments). +- **Escalation learnings** — when escalations led to crisp + decisions vs. when they bounced back as more questions. The + shape of a good escalation is a worth-remembering pattern. + +Bias learnings toward the **non-obvious**: a workflow quirk in +this team's tracker, a label semantics surprise, an +agent-capability gap that wasn't documented. Generic project +management facts are not worth memory. diff --git a/ampere-core/src/commonMain/composeResources/files/sparks/quality-agent.spark.md b/ampere-core/src/commonMain/composeResources/files/sparks/quality-agent.spark.md new file mode 100644 index 00000000..9288f654 --- /dev/null +++ b/ampere-core/src/commonMain/composeResources/files/sparks/quality-agent.spark.md @@ -0,0 +1,168 @@ +--- +id: quality-agent +name: Quality Agent +whenToUse: tasks that validate code quality, surface bugs, run targeted checks (syntax, style, logic, security, performance, testing), and learn which checks pay off for which kinds of code change +phases: PERCEIVE, PLAN, EXECUTE, LEARN +tags: quality, qa, validation, testing, review +agentRole: Quality Assurance +--- + +You are a Quality Assurance Agent responsible for: + +- Validating code quality and correctness against a set of typed + checks (syntax, style, logic, security, performance, testing). +- Running and interpreting test results in context. +- Identifying potential bugs and surfacing issues before they ship. +- Adhering to (and reinforcing) the workspace's coding standards. +- Learning which checks paid off for which kinds of code change so + future validation plans focus on what actually catches things. + +Your work is verification, not creation: you decide what to check +and how thoroughly to check it, then surface findings. You do not +write production code; the engineering, language, and framework +sparks above you do that. + +This spark stays neutral about the language and the tooling. +Specifics (which linter, which test runner, which security +scanner) come from the adjacent sparks and from the live state +injected at perception time. + +## When Perceiving + +The state you receive carries recent validation results, pending +code reviews, the catalog of known issue patterns, test coverage +data, and the most recent findings. + +When forming your perception: + +- **Current task** — what's being asked of you (validate a fresh + diff, follow up on a regression, characterise a flaky test, + audit a coverage gap)? +- **Current outcome** — `Success`, `Failure`, `Blank`, or other? + A prior failed validation is a strong signal to plan extra + checks for the same failure mode this time. +- **Recent validation history** — which checks ran, which passed, + which failed, against what code. A check that has caught nothing + in the last twenty runs deserves a lower priority than a check + that has caught something twice. +- **Pending reviews** — surface them with priority. CRITICAL and + HIGH priority reviews should drive the planning phase before + lower-priority work. +- **Known issue patterns** — patterns previously observed and + their detection rates. A pattern with a high detection rate + against this kind of code change earns a dedicated check step. +- **Test coverage** — line and branch coverage with uncovered + areas. Uncovered areas adjacent to a recent change are + high-value validation targets. +- **Recent findings** — severity-sorted findings from the last few + runs. ERROR and CRITICAL findings rarely vanish on their own; + if they're still present, plan to re-verify the suspected + fix. +- **Learned knowledge** — past `approach + learnings` pairs from + similar validations. If a prior check sequence missed something + important, prefer a sequence shaped by what was missed. +- **Available tools** — enumerate. Don't plan with tools that + aren't present. + +Your perception output is an `Idea` summarising what stands out. +Keep it factual. + +## When Planning + +You are the planning module of an autonomous QA agent. Turn the +task plus the perception ideas into a concrete validation plan +that the `plan_steps` tool will materialise. + +Sizing: + +- **3–6 check steps** is the sweet spot for a typical code-change + validation. Smaller than three usually means you're skimming; + larger than six usually means the work is two validations + pretending to be one. +- **1–2 steps** for narrow targeted checks (verify a single + regression, re-run a single failing test). + +For each step, attend to: + +- **Check type** — name the typed check (`syntax`, `style`, + `logic`, `security`, `performance`, `testing`). Don't conflate + categories; a step that mixes "style + logic" is two steps. +- **Effectiveness-weighted ordering** — when recalled knowledge + surfaces effectiveness numbers per check type, plan + higher-effectiveness checks first. When effectiveness is + unknown, default to syntax → style → logic → security → + performance → testing. +- **Coverage of commonly-missed issues** — when the recalled + knowledge surfaces a class of issue that prior runs have + missed (null-pointer issues, boundary conditions, concurrency + hazards, etc.), add an explicit step targeting that class even + if it duplicates an existing check. +- **Pure-reasoning checks** — most validation steps are pure + reasoning over the code (`toolToUse = null`). When a step + requires invoking a real tool (a test runner, a linter, a + static analyser), nominate that tool by id. + +Complexity calibration: the recalled `ValidationInsights` may +surface a `commonlyMissedIssues` list and per-check +effectiveness. When the missed-issues list is long (≥3 +patterns), bias the plan toward more thorough checks. When +average effectiveness is high (>0.8) and there are no missed +issues, a shorter plan is appropriate. + +Tool routing is **load-bearing**: every step that performs an +action must nominate the exact tool id. The executor routes +strictly by tool id and will fail fast on a missing or +unrecognised id. The JSON shape and parsing rules for plan +steps live with the `plan_steps` tool. Do not re-implement them +here. + +## When Executing + +Outcomes flow back as you execute. Use them to decide whether +the plan is still on track, needs a recovery step, or has +succeeded. + +For each outcome, attend to: + +- **`Success`** with no findings — the check ran clean. Record + which check it was so the effectiveness numbers update. +- **`Success`** with findings — the check ran and surfaced + issues. Capture each finding's severity, message, and + (if known) location. Severity drives whether downstream + steps should still run. +- **`Failure`** — the check itself failed to run (tool error, + missing dependency, malformed input). Treat as a process + problem, not a code-quality signal. Capture the failure mode + for learning. +- **Other outcome types** — record the class name so the next + reasoning step can decide what to do. + +Aggregate the run as `Total: N, Passed: P, Failed: F` where +"passed" means the check ran and surfaced no findings. A check +that ran cleanly is not the same as a check that wasn't run. + +## When Learning + +Extract reusable knowledge so future similar validations plan +better. + +For each completed task + plan + outcome triple, distil: + +- **Approach** — a short prefix identifying this as a QA task, + the task type, the plan size, and the check types exercised. + The approach summary is how future-you finds this memory. +- **Effectiveness learnings** — per check type, what fraction of + runs surfaced something worth fixing. If a check type + consistently catches nothing for this kind of code, record that + so future plans can deprioritise it. +- **Missed-issue learnings** — when a bug ships despite passing + validation, name the bug class so future plans can target it + explicitly. "Null pointer in optional value chain" is matchable; + "subtle bug" is not. +- **False-positive learnings** — when a check loudly flagged + something that turned out to be fine, record the pattern so + future plans either calibrate the check or skip it. + +Bias learnings toward the **non-obvious**: a workspace-specific +hazard or a check-tool quirk worth surprising. Generic QA +knowledge ("test edge cases") doesn't belong in memory. diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/AgentFactory.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/AgentFactory.kt index d3137969..755a2d49 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/AgentFactory.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/AgentFactory.kt @@ -6,9 +6,11 @@ import link.socket.ampere.agents.config.AgentConfiguration import link.socket.ampere.agents.config.CognitiveConfig import link.socket.ampere.agents.domain.cognition.Spark import link.socket.ampere.agents.domain.cognition.sparks.AmpereProjectSpark +import link.socket.ampere.agents.domain.cognition.sparks.AmpereSpikeFlags +import link.socket.ampere.agents.domain.cognition.sparks.DefaultPhaseSparkLibrary import link.socket.ampere.agents.domain.cognition.sparks.LanguageSpark +import link.socket.ampere.agents.domain.cognition.sparks.PhaseSparkLibrary import link.socket.ampere.agents.domain.cognition.sparks.ProjectSpark -import link.socket.ampere.agents.domain.cognition.sparks.RoleSpark import link.socket.ampere.agents.domain.memory.AgentMemoryService import link.socket.ampere.agents.domain.state.AgentState import link.socket.ampere.agents.events.api.AgentEventApi @@ -18,12 +20,20 @@ import link.socket.ampere.agents.execution.request.ExecutionContext import link.socket.ampere.agents.execution.tools.Tool import link.socket.ampere.agents.execution.tools.ToolAskHuman import link.socket.ampere.agents.execution.tools.ToolCreateIssues +import link.socket.ampere.agents.execution.tools.ToolReadCodeFile import link.socket.ampere.agents.execution.tools.ToolWriteCodeFile +import link.socket.ampere.agents.execution.tools.git.ToolCommit +import link.socket.ampere.agents.execution.tools.git.ToolCreateBranch +import link.socket.ampere.agents.execution.tools.git.ToolCreatePullRequest +import link.socket.ampere.agents.execution.tools.git.ToolGitStatus +import link.socket.ampere.agents.execution.tools.git.ToolPush +import link.socket.ampere.agents.execution.tools.git.ToolStageFiles import link.socket.ampere.domain.agent.bundled.WriteCodeAgent import link.socket.ampere.domain.ai.configuration.AIConfiguration import link.socket.ampere.domain.ai.configuration.AIConfigurationFactory import link.socket.ampere.domain.llm.LlmProvider import link.socket.ampere.integrations.issues.IssueTrackerProvider +import link.socket.ampere.util.runBlockingCompat enum class AgentType { CODE, @@ -41,7 +51,7 @@ enum class AgentType { * * **Spark Integration (Ticket #226)**: * Each agent is initialized with a proper Spark stack based on their type: - * - CognitiveAffinity: Set by the agent class (e.g., ANALYTICAL for CodeAgent) + * - CognitiveAffinity: Set by the SparkBasedAgent factory (e.g. ANALYTICAL for the Code factory) * - ProjectSpark: Applied if provided (defaults to AmpereProjectSpark) * - RoleSpark: Applied based on agent type * - LanguageSpark: Applied for code agents (defaults to Kotlin) @@ -73,10 +83,22 @@ class AgentFactory( toolWriteCodeFileOverride ?: ToolWriteCodeFile(AgentActionAutonomy.ASK_BEFORE_ACTION) private val toolCreateIssues: Tool = - ToolCreateIssues(AgentActionAutonomy.ACT_WITH_NOTIFICATION) + ToolCreateIssues( + requiredAgentAutonomy = AgentActionAutonomy.ACT_WITH_NOTIFICATION, + parameterStrategy = link.socket.ampere.agents.definition.project.ProjectParams.IssueCreation( + repository = repository ?: ".", + availableAgents = emptyList(), + existingIssues = emptyList(), + ), + ) private val toolAskHuman: Tool = - ToolAskHuman(AgentActionAutonomy.ASK_BEFORE_ACTION) + ToolAskHuman( + requiredAgentAutonomy = AgentActionAutonomy.ASK_BEFORE_ACTION, + parameterStrategy = link.socket.ampere.agents.definition.project.ProjectParams.HumanEscalation( + agentRole = "Project Manager", + ), + ) private val effectiveAiConfiguration: AIConfiguration get() = aiConfiguration ?: AIConfigurationFactory.getDefaultConfiguration() @@ -96,6 +118,22 @@ class AgentFactory( private val effectiveProjectSpark: ProjectSpark get() = projectSpark ?: AmpereProjectSpark.spark + /** + * Bundled `.spark.md` library, loaded lazily on first agent creation. + * The factory's [createAgent] path attaches this library to every + * spark-based agent it builds so the declarative per-phase guidance + * (`code-agent.spark.md`, `product-agent.spark.md`, etc.) activates + * during the cognitive loop. + * + * Loading is suspend but the existing public `create` API is + * synchronous, so we resolve the library via `runBlocking` once and + * cache it. The load reads bundled resources and parses markdown — + * cheap enough at startup that the single blocking call is fine. + */ + private val phaseSparkLibrary: PhaseSparkLibrary by lazy { + runBlockingCompat { DefaultPhaseSparkLibrary.load() } + } + /** * Get an event API for publishing events on behalf of an agent. * @@ -122,6 +160,7 @@ class AgentFactory( agentType: AgentType, ): A { val agent = createAgent(agentType) + attachPhaseSparkLibrary(agent) applySparkStack(agent, agentType) if (agent is ObservableAgent<*>) { agent.emitCognitiveSnapshot() @@ -130,6 +169,21 @@ class AgentFactory( return agent as A } + /** + * Wires the bundled declarative spark library into the agent so its + * `.spark.md` per-phase contributions activate during the cognitive + * loop. Only spark-based agents have the setter (the legacy typed + * agents are gone after Waves 1–2). Also flips on the runtime gate + * for the declarative-spark path; the flag defaults to off for + * backward-compat callers that wire agents by hand without a + * library. + */ + private fun attachPhaseSparkLibrary(agent: AutonomousAgent<*>) { + if (agent !is SparkBasedAgent<*>) return + agent.setPhaseSparkLibrary(phaseSparkLibrary) + AmpereSpikeFlags.declarativeSparksEnabled = true + } + /** * Creates an agent of the specified type without applying Sparks. * Use this only when you need to manually configure the Spark stack. @@ -146,55 +200,66 @@ class AgentFactory( private fun createAgent(agentType: AgentType): AutonomousAgent = when (agentType) { AgentType.CODE -> { - val agentId = generateUUID("CodeWriterAgent") + val agentId = generateUUID("SparkBasedAgent-Code") val eventApi = eventApiFactory?.invoke(agentId) - CodeAgent( - agentConfiguration = agentConfiguration, - toolWriteCodeFile = toolWriteCodeFile, - coroutineScope = scope, - memoryServiceFactory = memoryServiceFactory, - issueTrackerProvider = issueTrackerProvider, - repository = repository, - eventApiOverride = eventApi, - observabilityScope = scope, + val memoryService = memoryServiceFactory?.invoke(agentId) + SparkBasedAgent.Code( agentId = agentId, + aiConfiguration = effectiveAiConfiguration, + eventApi = eventApi, + memoryService = memoryService, + llmProvider = llmProvider, + observabilityScope = scope, + tools = buildSet { + add(toolWriteCodeFile) + add(ToolReadCodeFile(AgentActionAutonomy.FULLY_AUTONOMOUS)) + add(ToolCreateBranch()) + add(ToolStageFiles()) + add(ToolCommit()) + add(ToolPush()) + add(ToolCreatePullRequest()) + add(ToolGitStatus()) + }, ) } AgentType.PRODUCT -> { - val agentId = generateUUID("ProductManagerAgent") + val agentId = generateUUID("SparkBasedAgent-Product") val eventApi = eventApiFactory?.invoke(agentId) - ProductAgent( - agentConfiguration = agentConfiguration, - ticketOrchestrator = ticketOrchestrator, - memoryServiceFactory = memoryServiceFactory, - eventApiOverride = eventApi, - observabilityScope = scope, + val memoryService = memoryServiceFactory?.invoke(agentId) + SparkBasedAgent.Product( agentId = agentId, + aiConfiguration = effectiveAiConfiguration, + eventApi = eventApi, + memoryService = memoryService, + llmProvider = llmProvider, + observabilityScope = scope, ) } AgentType.PROJECT -> { - val agentId = generateUUID("ProjectManagerAgent") + val agentId = generateUUID("SparkBasedAgent-Project") val eventApi = eventApiFactory?.invoke(agentId) - ProjectAgent( - agentConfiguration = agentConfiguration, - toolCreateIssues = toolCreateIssues, - toolAskHuman = toolAskHuman, - coroutineScope = scope, - memoryServiceFactory = memoryServiceFactory, - eventApiOverride = eventApi, - observabilityScope = scope, + val memoryService = memoryServiceFactory?.invoke(agentId) + SparkBasedAgent.Project( agentId = agentId, + aiConfiguration = effectiveAiConfiguration, + eventApi = eventApi, + memoryService = memoryService, + llmProvider = llmProvider, + observabilityScope = scope, + tools = setOf(toolCreateIssues, toolAskHuman), ) } AgentType.QUALITY -> { - val agentId = generateUUID("QualityAssuranceAgent") + val agentId = generateUUID("SparkBasedAgent-Quality") val eventApi = eventApiFactory?.invoke(agentId) - QualityAgent( - agentConfiguration = agentConfiguration, - memoryServiceFactory = memoryServiceFactory, - eventApiOverride = eventApi, - observabilityScope = scope, + val memoryService = memoryServiceFactory?.invoke(agentId) + SparkBasedAgent.Quality( agentId = agentId, + aiConfiguration = effectiveAiConfiguration, + eventApi = eventApi, + memoryService = memoryService, + llmProvider = llmProvider, + observabilityScope = scope, ) } } @@ -211,21 +276,21 @@ class AgentFactory( // Apply ProjectSpark first applySpark(agent, effectiveProjectSpark) - // Apply appropriate RoleSpark based on agent type + // Apply appropriate RoleSpark based on agent type. + // Note: the `SparkBasedAgent.(...)` factories each apply + // their own RoleSpark at construction time, so the branches here + // only layer additional sparks on top of what the factory already + // stacked. The CODE branch adds a language spark; the PRODUCT, + // PROJECT, and QUALITY branches add nothing further (their role + // sparks are already applied by their respective factories). when (agentType) { AgentType.CODE -> { - applySpark(agent, RoleSpark.Code) applySpark(agent, LanguageSpark.Kotlin) } - AgentType.PRODUCT -> { - applySpark(agent, RoleSpark.Planning) - } - AgentType.PROJECT -> { - applySpark(agent, RoleSpark.Planning) - } - AgentType.QUALITY -> { - applySpark(agent, RoleSpark.Code) - } + AgentType.PRODUCT, + AgentType.PROJECT, + AgentType.QUALITY, + -> Unit } } diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/CodeAgent.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/CodeAgent.kt deleted file mode 100644 index 06f201e6..00000000 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/CodeAgent.kt +++ /dev/null @@ -1,1347 +0,0 @@ -package link.socket.ampere.agents.definition - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import kotlinx.datetime.Clock -import link.socket.ampere.agents.config.AgentConfiguration -import link.socket.ampere.agents.definition.code.CodeParams -import link.socket.ampere.agents.definition.code.CodePrompts -import link.socket.ampere.agents.definition.code.CodeState -import link.socket.ampere.agents.definition.code.IssueWorkflowStatus -import link.socket.ampere.agents.domain.cognition.CognitiveAffinity -import link.socket.ampere.agents.domain.knowledge.Knowledge -import link.socket.ampere.agents.domain.outcome.ExecutionOutcome -import link.socket.ampere.agents.domain.outcome.Outcome -import link.socket.ampere.agents.domain.reasoning.AgentReasoning -import link.socket.ampere.agents.domain.reasoning.DefaultTaskFactory -import link.socket.ampere.agents.domain.reasoning.Idea -import link.socket.ampere.agents.domain.reasoning.KnowledgeExtractor -import link.socket.ampere.agents.domain.reasoning.Perception -import link.socket.ampere.agents.domain.reasoning.PerceptionContextBuilder -import link.socket.ampere.agents.domain.reasoning.Plan -import link.socket.ampere.agents.domain.reasoning.StepContext -import link.socket.ampere.agents.domain.reasoning.StepResult -import link.socket.ampere.agents.domain.state.AgentState -import link.socket.ampere.agents.domain.status.TaskStatus -import link.socket.ampere.agents.domain.status.TicketStatus -import link.socket.ampere.agents.domain.task.AssignedTo -import link.socket.ampere.agents.domain.task.MeetingTask -import link.socket.ampere.agents.domain.task.PMTask -import link.socket.ampere.agents.domain.task.Task -import link.socket.ampere.agents.domain.task.TicketTask -import link.socket.ampere.agents.environment.workspace.ExecutionWorkspace -import link.socket.ampere.agents.events.api.AgentEventApi -import link.socket.ampere.agents.events.tickets.Ticket -import link.socket.ampere.agents.events.tickets.TicketPriority -import link.socket.ampere.agents.events.tickets.TicketType -import link.socket.ampere.agents.events.utils.generateUUID -import link.socket.ampere.agents.execution.executor.Executor -import link.socket.ampere.agents.execution.executor.FunctionExecutor -import link.socket.ampere.agents.execution.request.ExecutionConstraints -import link.socket.ampere.agents.execution.request.ExecutionContext -import link.socket.ampere.agents.execution.request.ExecutionRequest -import link.socket.ampere.agents.execution.tools.Tool -import link.socket.ampere.agents.execution.tools.git.ToolCommit -import link.socket.ampere.agents.execution.tools.git.ToolCreateBranch -import link.socket.ampere.agents.execution.tools.git.ToolCreatePullRequest -import link.socket.ampere.agents.execution.tools.git.ToolGitStatus -import link.socket.ampere.agents.execution.tools.git.ToolPush -import link.socket.ampere.agents.execution.tools.git.ToolStageFiles -import link.socket.ampere.integrations.issues.ExistingIssue -import link.socket.ampere.integrations.issues.IssueQuery -import link.socket.ampere.integrations.issues.IssueState -import link.socket.ampere.integrations.issues.IssueUpdate -import link.socket.ampere.util.ioDispatcher -import link.socket.ampere.util.runBlockingCompat - -/** - * Code Writer Agent - Autonomous code generation and file writing. - * - * This agent specializes in: - * - Generating production-quality code from task descriptions - * - Writing code files to the workspace - * - Reading existing code for context - * - Learning from execution outcomes to improve future code generation - * - * Uses the unified AgentReasoning infrastructure for all cognitive operations. - */ -open class CodeAgent( - override val agentConfiguration: AgentConfiguration, - private val toolWriteCodeFile: Tool, - private val coroutineScope: CoroutineScope, - override val initialState: CodeState = CodeState.blank, - private val toolReadCodeFile: Tool? = null, - private val executor: Executor = FunctionExecutor.create(), - memoryServiceFactory: ((AgentId) -> link.socket.ampere.agents.domain.memory.AgentMemoryService)? = null, - reasoningOverride: AgentReasoning? = null, - private val issueTrackerProvider: link.socket.ampere.integrations.issues.IssueTrackerProvider? = null, - private val repository: String? = null, - private val toolCreateBranch: Tool? = ToolCreateBranch(), - private val toolStageFiles: Tool? = ToolStageFiles(), - private val toolCommit: Tool? = ToolCommit(), - private val toolPush: Tool? = ToolPush(), - private val toolCreatePR: Tool? = ToolCreatePullRequest(), - private val toolGitStatus: Tool? = ToolGitStatus(), - private val eventApiOverride: AgentEventApi? = null, - private val observabilityScope: CoroutineScope = CoroutineScope(Dispatchers.Default), - private val agentId: AgentId = generateUUID("CodeWriterAgent"), -) : ObservableAgent(eventApiOverride, observabilityScope) { - - override val id: AgentId = agentId - - override val memoryService: link.socket.ampere.agents.domain.memory.AgentMemoryService? = - memoryServiceFactory?.invoke(id) - - override val requiredTools: Set> = buildSet { - add(toolWriteCodeFile) - toolReadCodeFile?.let { add(it) } - toolCreateBranch?.let { add(it) } - toolStageFiles?.let { add(it) } - toolCommit?.let { add(it) } - toolPush?.let { add(it) } - toolCreatePR?.let { add(it) } - toolGitStatus?.let { add(it) } - } - - /** - * CodeAgent uses ANALYTICAL cognitive affinity. - * - * This shapes the agent to break problems into verifiable steps, - * prioritize correctness, and trace logic chains - ideal for - * code generation, review, and debugging. - */ - override val affinity: CognitiveAffinity = CognitiveAffinity.ANALYTICAL - - // ======================================================================== - // Unified Reasoning - All cognitive logic in one place - // ======================================================================== - - private val reasoning: AgentReasoning = reasoningOverride ?: AgentReasoning.create( - agentConfiguration, - id, - eventApiOverride, - ) { - agentRole = "Code Writer" - availableTools = requiredTools - this.executor = this@CodeAgent.executor - - perception { - contextBuilder = { state -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - buildPerceptionContext(state) - } - } - } - } - - planning { - taskFactory = DefaultTaskFactory - customPrompt = { task, ideas, tools, knowledge -> - buildPlanningPrompt(task, ideas) - } - } - - execution { - registerStrategy(toolWriteCodeFile.id, CodeParams.CodeWriting()) - toolReadCodeFile?.let { tool -> - registerStrategy(tool.id, CodeParams.CodeReading()) - } - } - - evaluation { - contextBuilder = { outcomes -> buildOutcomeContext(outcomes) } - } - - knowledge { - extractor = { outcome, task, plan -> - KnowledgeExtractor.extract(outcome, task, plan) { - approach { - prefix("Code Task") - taskType(task) - planSize(plan) - } - learnings { - fromOutcome(outcome) - } - } - } - } - } - - // ======================================================================== - // PROPEL Cognitive Functions - Delegate to reasoning infrastructure - // ======================================================================== - - @Suppress("UNCHECKED_CAST") - override val runLLMToEvaluatePerception: (perception: Perception) -> Idea = - { perception -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.evaluatePerception(perception) - } - } - } - - override val runLLMToPlan: (task: Task, ideas: List) -> Plan = - { task, ideas -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.generatePlan(task, ideas) - } - } - } - - override val runLLMToExecuteTask: (task: Task) -> Outcome = - { task -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - executeTaskWithReasoning(task) - } - } - } - - override val runLLMToExecuteTool: (tool: Tool<*>, request: ExecutionRequest<*>) -> ExecutionOutcome = - { tool, request -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.executeTool(tool, request) - } - } - } - - override val runLLMToEvaluateOutcomes: (outcomes: List) -> Idea = - { outcomes -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.evaluateOutcomes(outcomes, memoryService).summaryIdea - } - } - } - - override fun extractKnowledgeFromOutcome( - outcome: Outcome, - task: Task, - plan: Plan, - ): Knowledge.FromOutcome = reasoning.extractKnowledge(outcome, task, plan) - - override fun callLLM(prompt: String): String = - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.callLLM(prompt) - } - } - - // ======================================================================== - // Task Execution - Uses PlanExecutor for orchestration - // ======================================================================== - - /** - * Determines the operation type from a task description. - * - * Analyzes the task description to classify it into one of the supported - * operation types (code writing, Git operations, etc.). - */ - private enum class OperationType { - CODE_WRITE, - GIT_CREATE_BRANCH, - GIT_STAGE_FILES, - GIT_COMMIT, - GIT_PUSH, - GIT_CREATE_PR, - GIT_STATUS, - UNKNOWN, - } - - /** - * Detects the operation type from task description. - */ - private fun detectOperationType(task: Task): OperationType { - if (task !is Task.CodeChange) return OperationType.UNKNOWN - - val desc = task.description.lowercase() - return when { - desc.contains("create branch") || desc.contains("new branch") -> OperationType.GIT_CREATE_BRANCH - desc.contains("stage files") || desc.contains("git add") -> OperationType.GIT_STAGE_FILES - desc.contains("commit") -> OperationType.GIT_COMMIT - desc.contains("push") || desc.contains("git push") -> OperationType.GIT_PUSH - desc.contains("create pr") || - desc.contains("pull request") || - desc.contains("create pull request") -> OperationType.GIT_CREATE_PR - desc.contains("git status") || desc.contains("check status") -> OperationType.GIT_STATUS - desc.contains("write") || desc.contains("create file") || desc.contains("modify file") || - desc.contains("implement") || desc.contains("add code") -> OperationType.CODE_WRITE - else -> OperationType.CODE_WRITE // Default to code write for unrecognized patterns - } - } - - private suspend fun executeTaskWithReasoning(task: Task): Outcome { - if (task is Task.Blank) { - return Outcome.blank - } - - if (task !is Task.CodeChange) { - return createTaskFailureOutcome( - task, - "Unsupported task type: ${task::class.simpleName}. " + - "CodeWriterAgent currently only supports Task.CodeChange", - ) - } - - val plan = reasoning.generatePlan(task, emptyList()) - return reasoning.executePlan(plan) { step, context -> - executeStep(step, context) - }.outcome - } - - /** - * Executes a single step in the plan. - * - * This method routes the step to the appropriate handler based on the - * detected operation type. It supports: - * - Code writing (with LLM generation) - * - Git operations (branching, committing, pushing, PR creation) - * - * @param step The task to execute - * @param context The current step context with accumulated state - * @return StepResult indicating success, failure, or skip - */ - private suspend fun executeStep(step: Task, context: StepContext): StepResult { - if (step !is Task.CodeChange) { - return StepResult.skip( - description = "Unknown step type", - reason = "Step type ${step::class.simpleName} not supported by CodeWriterAgent", - ) - } - - // Route to appropriate handler based on operation type - val operationType = detectOperationType(step) - return when (operationType) { - OperationType.CODE_WRITE -> executeCodeWriteStep(step, context) - OperationType.GIT_CREATE_BRANCH -> executeGitCreateBranchStep(step, context) - OperationType.GIT_STAGE_FILES -> executeGitStageFilesStep(step, context) - OperationType.GIT_COMMIT -> executeGitCommitStep(step, context) - OperationType.GIT_PUSH -> executeGitPushStep(step, context) - OperationType.GIT_CREATE_PR -> executeGitCreatePRStep(step, context) - OperationType.GIT_STATUS -> executeGitStatusStep(step, context) - OperationType.UNKNOWN -> StepResult.failure( - description = step.description, - error = "Could not determine operation type from description", - isCritical = false, - ) - } - } - - private suspend fun executeCodeChange(task: Task.CodeChange): ExecutionOutcome { - val request = createExecutionRequest(task) - return reasoning.executeTool(toolWriteCodeFile, request) - } - - private fun createExecutionRequest( - task: Task.CodeChange, - ): ExecutionRequest { - val ticket = createTicketForTask(task) - val workspace = ExecutionWorkspace(baseDirectory = ".") - - return ExecutionRequest( - context = ExecutionContext.Code.WriteCode( - executorId = id, - ticket = ticket, - task = task, - instructions = task.description, - knowledgeFromPastMemory = emptyList(), - workspace = workspace, - instructionsPerFilePath = emptyList(), // Will be filled by strategy - ), - constraints = ExecutionConstraints( - requireTests = false, - requireLinting = false, - ), - ) - } - - // ======================================================================== - // Step Execution Handlers - // ======================================================================== - - /** - * Executes a code writing step with optional LLM code generation. - * - * If the task description indicates that code needs to be generated (e.g., contains - * "{{GENERATE}}" or requires implementation from requirements), the LLM will be - * invoked to generate the code before writing. - * - * @param task The code writing task - * @param context The step context containing issue context and previously written files - * @return StepResult with success/failure and updated context - */ - private suspend fun executeCodeWriteStep( - task: Task.CodeChange, - context: StepContext, - ): StepResult { - val outcome = executeCodeChange(task) - return when (outcome) { - is ExecutionOutcome.CodeChanged.Success -> { - // Track created/modified files in context - val existingFiles = context.get>("created_files") ?: emptyList() - val updatedFiles = existingFiles + outcome.changedFiles - - StepResult.success( - description = "Write code: ${task.description}", - details = "Modified ${outcome.changedFiles.size} files: ${outcome.changedFiles.joinToString(", ")}", - contextUpdates = mapOf( - "created_files" to updatedFiles, - "modified_files" to updatedFiles, - ), - ) - } - is ExecutionOutcome.CodeChanged.Failure -> { - StepResult.failure( - description = "Write code: ${task.description}", - error = outcome.error.message, - isCritical = true, - ) - } - is ExecutionOutcome.Failure -> { - StepResult.failure( - description = "Write code: ${task.description}", - error = "Execution failed", - isCritical = true, - ) - } - else -> StepResult.success( - description = "Write code: ${task.description}", - details = "Completed", - ) - } - } - - /** - * Executes Git create branch operation. - * - * Extracts branch name from task description and creates the branch. - * Stores branch name in context for subsequent Git operations. - */ - private suspend fun executeGitCreateBranchStep( - task: Task.CodeChange, - context: StepContext, - ): StepResult { - // Tool not configured - if (toolCreateBranch == null) { - return StepResult.skip( - description = task.description, - reason = "Git create branch tool not configured", - ) - } - - // TODO: Extract branch name and issue number from task description - // For now, return success to allow testing - return StepResult.success( - description = task.description, - details = "Branch creation would be executed here", - contextUpdates = mapOf("branch_name" to "feature/placeholder"), - ) - } - - /** - * Executes Git stage files operation. - */ - private suspend fun executeGitStageFilesStep( - task: Task.CodeChange, - context: StepContext, - ): StepResult { - if (toolStageFiles == null) { - return StepResult.skip( - description = task.description, - reason = "Git stage files tool not configured", - ) - } - - // Get list of created/modified files from context - val files = context.get>("created_files") ?: emptyList() - - return StepResult.success( - description = task.description, - details = "Would stage ${files.size} files", - ) - } - - /** - * Executes Git commit operation. - */ - private suspend fun executeGitCommitStep( - task: Task.CodeChange, - context: StepContext, - ): StepResult { - if (toolCommit == null) { - return StepResult.skip( - description = task.description, - reason = "Git commit tool not configured", - ) - } - - val issueNumber = context.get("issue_number") - val files = context.get>("created_files") ?: emptyList() - - return StepResult.success( - description = task.description, - details = "Would commit ${files.size} files${issueNumber?.let { " for issue #$it" } ?: ""}", - ) - } - - /** - * Executes Git push operation. - */ - private suspend fun executeGitPushStep( - task: Task.CodeChange, - context: StepContext, - ): StepResult { - if (toolPush == null) { - return StepResult.skip( - description = task.description, - reason = "Git push tool not configured", - ) - } - - val branchName = context.get("branch_name") - - return StepResult.success( - description = task.description, - details = "Would push branch: ${branchName ?: "unknown"}", - ) - } - - /** - * Executes Git create PR operation with full PR creation workflow. - * - * This method: - * 1. Retrieves issue context and changed files from step context - * 2. Uses CodeAgentGitHelpers to generate PR title, body, and reviewers - * 3. Creates a pull request via the Git tool - * 4. Updates issue status to IN_REVIEW - * 5. Stores PR details in context for subsequent steps - * - * @param task The PR creation task - * @param context Step context containing issue, branch, and file information - * @return StepResult with PR creation outcome - */ - private suspend fun executeGitCreatePRStep( - task: Task.CodeChange, - context: StepContext, - ): StepResult { - if (toolCreatePR == null) { - return StepResult.skip( - description = task.description, - reason = "Git create PR tool not configured", - ) - } - - // Extract context - val issueNumber = context.get("issue_number") - val branchName = context.get("branch_name") ?: "feature/unknown" - val files = context.get>("created_files") ?: emptyList() - - // For now, create a basic PR description since we don't have full issue details - // TODO: Query issue details from issueTrackerProvider when available - val prTitle = if (issueNumber != null) { - "feat: Implement #$issueNumber" - } else { - task.description.take(50) - } - - val prBody = buildPRBody(issueNumber, files, task.description) - val reviewers = determineReviewers(files) - - // TODO: Execute actual PR creation when Git tool implementation is complete - // For now, return success with placeholder data - val prNumber = 1 // Placeholder - - // Update issue status to IN_REVIEW after PR creation - if (issueNumber != null) { - updateIssueStatusSafely( - issueNumber = issueNumber, - status = IssueWorkflowStatus.IN_REVIEW, - comment = "Pull request #$prNumber created. Reviewers: ${reviewers.joinToString(", ")}", - ) - } - - return StepResult.success( - description = task.description, - details = buildString { - append("Created PR: $prTitle\n") - append("Branch: $branchName\n") - append("Files: ${files.size}\n") - append("Reviewers: ${reviewers.joinToString(", ")}") - }, - contextUpdates = mapOf( - "pr_created" to true, - "pr_number" to prNumber, - "pr_title" to prTitle, - "pr_reviewers" to reviewers, - ), - ) - } - - /** - * Builds a formatted PR body with summary, changes, and testing checklist. - * - * Format follows GitHub best practices: - * - Summary section explaining the changes - * - Changes section listing modified files - * - Testing checklist for verification - * - Auto-linking to issue with "Closes #N" - */ - private fun buildPRBody( - issueNumber: Int?, - changedFiles: List, - description: String, - ): String = buildString { - appendLine("## Summary") - appendLine() - if (issueNumber != null) { - appendLine("This PR implements #$issueNumber.") - } else { - appendLine(description) - } - appendLine() - - appendLine("## Changes") - appendLine() - if (changedFiles.isNotEmpty()) { - val groupedFiles = groupFilesByType(changedFiles) - groupedFiles.forEach { (type, files) -> - appendLine("**$type:**") - files.take(10).forEach { file -> - appendLine("- `$file`") - } - if (files.size > 10) { - appendLine("- ... and ${files.size - 10} more") - } - appendLine() - } - } else { - appendLine("- Implementation changes") - appendLine() - } - - appendLine("## Testing") - appendLine() - val testFiles = changedFiles.filter { isTestFile(it) } - if (testFiles.isNotEmpty()) { - appendLine("Added/updated tests:") - testFiles.forEach { appendLine("- `$it`") } - } else { - appendLine("⚠️ No test changes in this PR. Consider adding tests.") - } - appendLine() - - appendLine("## Checklist") - appendLine() - appendLine("- [x] Code follows project conventions") - appendLine("- [x] Changes are properly scoped to issue") - appendLine("- [ ] Tests pass locally") - appendLine("- [ ] Documentation updated if needed") - appendLine() - - if (issueNumber != null) { - appendLine("---") - appendLine("Closes #$issueNumber") - appendLine() - } - - appendLine("*This PR was created by CodeWriterAgent*") - } - - /** - * Determines reviewers based on changed files. - * - * Review assignment strategy: - * - Always includes QATestingAgent for code review - * - Adds SecurityReviewAgent for security-sensitive files - * - Adds PerformanceOptimizationAgent for performance-critical files - * - Can add human reviewers for high-risk changes (TODO) - */ - private fun determineReviewers(changedFiles: List): List { - val reviewers = mutableListOf() - - // Always add QA agent - reviewers.add("QATestingAgent") - - // Add specialized reviewers based on file types - if (changedFiles.any { isSensitiveFile(it) }) { - reviewers.add("SecurityReviewAgent") - } - - if (changedFiles.any { isPerformanceCriticalFile(it) }) { - reviewers.add("PerformanceOptimizationAgent") - } - - return reviewers.distinct() - } - - /** - * Checks if a file is security-sensitive and requires security review. - */ - private fun isSensitiveFile(path: String): Boolean { - return path.contains("security", ignoreCase = true) || - path.contains("auth", ignoreCase = true) || - path.contains("secret", ignoreCase = true) || - path.contains("credential", ignoreCase = true) || - path.contains("password", ignoreCase = true) || - path.endsWith(".gradle.kts") || - path == "build.gradle.kts" - } - - /** - * Checks if a file is performance-critical. - */ - private fun isPerformanceCriticalFile(path: String): Boolean { - return path.contains("performance", ignoreCase = true) || - path.contains("optimization", ignoreCase = true) || - path.contains("cache", ignoreCase = true) || - path.contains("database", ignoreCase = true) || - path.contains("query", ignoreCase = true) - } - - /** - * Checks if a file is a test file. - */ - private fun isTestFile(path: String): Boolean { - return path.contains("/test/", ignoreCase = true) || - path.contains("Test.kt", ignoreCase = true) || - path.endsWith("Spec.kt") - } - - /** - * Groups files by type for organized PR display. - */ - private fun groupFilesByType(files: List): Map> { - return files.groupBy { file -> - when { - isTestFile(file) -> "Tests" - file.endsWith(".md") -> "Documentation" - file.endsWith(".gradle.kts") || file.endsWith(".gradle") -> "Build" - file.endsWith(".json") || file.endsWith(".yml") || file.endsWith(".yaml") -> "Configuration" - else -> "Source Code" - } - } - } - - /** - * Executes Git status check. - */ - private suspend fun executeGitStatusStep( - task: Task.CodeChange, - context: StepContext, - ): StepResult { - if (toolGitStatus == null) { - return StepResult.skip( - description = task.description, - reason = "Git status tool not configured", - ) - } - - return StepResult.success( - description = task.description, - details = "Git status check completed", - ) - } - - // ======================================================================== - // Issue Status Management - // ======================================================================== - - /** - * Updates issue status with label changes and optional comment. - * - * This method manages the issue workflow by: - * 1. Fetching the current issue to get existing labels - * 2. Adding workflow status labels (e.g., "in-progress", "in-review") - * 3. Removing superseded status labels (e.g., removing "assigned" when adding "in-progress") - * 4. Adding a comment to provide human-readable context - * - * The label updates provide GitHub-visible progress tracking, while comments - * explain the status change and provide details (e.g., blocker reasons, PR links). - * - * @param issueNumber The issue number to update - * @param status The new workflow status - * @param comment Optional human-readable comment explaining the status change - * @return Result indicating success or failure - */ - internal suspend fun updateIssueStatus( - issueNumber: Int, - status: IssueWorkflowStatus, - comment: String? = null, - ): Result { - val provider = issueTrackerProvider - val repo = repository - - if (provider == null || repo == null) { - return Result.failure( - IllegalStateException("Issue tracker provider or repository not configured"), - ) - } - - // Fetch current issue to get existing labels - val currentIssue = provider.queryIssues( - repository = repo, - query = IssueQuery( - state = IssueState.Open, - limit = 1, - ), - ).getOrElse { emptyList() } - .find { it.number == issueNumber } - ?: return Result.failure( - IllegalArgumentException("Issue #$issueNumber not found"), - ) - - // Calculate new labels by adding/removing as specified - val currentLabels = currentIssue.labels.toMutableSet() - currentLabels.removeAll(status.removeLabels.toSet()) - currentLabels.addAll(status.addLabels) - - // Build update with new labels and optional comment - val update = IssueUpdate( - labels = currentLabels.toList(), - ) - - // Update the issue - val updateResult = provider.updateIssue( - repository = repo, - issueNumber = issueNumber, - update = update, - ) - - // Add comment if provided and update succeeded - if (comment != null && updateResult.isSuccess) { - val commentText = "${status.emoji} $comment" - // Note: IssueUpdate doesn't support adding comments directly - // TODO: Add comment via separate API call when available - // For now, the label changes provide the status visibility - } - - return updateResult - } - - /** - * Updates issue status and handles failures gracefully. - * - * Logs errors but doesn't throw, allowing workflow to continue even if - * status updates fail (e.g., network issues, API limits). - * - * @param issueNumber The issue number to update - * @param status The new workflow status - * @param comment Optional comment - */ - internal suspend fun updateIssueStatusSafely( - issueNumber: Int, - status: IssueWorkflowStatus, - comment: String, - ) { - updateIssueStatus(issueNumber, status, comment) - .onFailure { error -> - println("Warning: Failed to update issue #$issueNumber status to ${status.name}: ${error.message}") - } - } - - /** - * Attempt to claim an unassigned issue using optimistic locking. - * - * This method implements race condition protection when multiple CodeAgent instances - * attempt to claim the same issue simultaneously. It: - * 1. Reads the current issue state - * 2. Checks if already claimed or in another workflow status - * 3. Updates to CLAIMED status - * 4. Verifies the claim succeeded (detects if another agent claimed it first) - * - * The verification step is critical: if two agents update simultaneously, both will - * succeed at step 3, but only one will see CLAIMED status at step 4. The other will - * see a different status and know it lost the race. - * - * @param issueNumber The issue number to claim - * @return Success if claimed, Failure if: - * - Provider or repository not configured - * - Issue not found - * - Issue already claimed or in progress - * - Another agent won the race condition - * - Any error during the claim process - */ - suspend fun claimIssue(issueNumber: Int): Result { - val provider = issueTrackerProvider - ?: return Result.failure(IllegalStateException("IssueTrackerProvider not configured")) - val repo = repository - ?: return Result.failure(IllegalStateException("Repository not configured")) - - try { - // 1. Read current issue state - val currentIssue = provider.queryIssues( - repository = repo, - query = IssueQuery( - state = IssueState.Open, - limit = 100, - ), - ).getOrNull()?.find { it.number == issueNumber } - ?: return Result.failure(IllegalArgumentException("Issue #$issueNumber not found")) - - // 2. Check if already claimed - val currentStatus = IssueWorkflowStatus.fromLabels(currentIssue.labels) - if (currentStatus != null && currentStatus != IssueWorkflowStatus.CLAIMED) { - return Result.failure( - IllegalStateException("Issue already in ${currentStatus.name} status"), - ) - } - - // If already CLAIMED by someone (has 'assigned' label), don't re-claim - if (currentStatus == IssueWorkflowStatus.CLAIMED) { - return Result.failure( - IllegalStateException("Issue already claimed"), - ) - } - - // 3. Update to CLAIMED status - val updateResult = updateIssueStatus( - issueNumber = issueNumber, - status = IssueWorkflowStatus.CLAIMED, - comment = "CodeAgent claiming this issue", - ) - - if (updateResult.isFailure) { - return Result.failure( - updateResult.exceptionOrNull() ?: Exception("Failed to claim issue"), - ) - } - - // 4. Verify we got it (check for race condition) - // Small delay to let GitHub propagate the update - kotlinx.coroutines.delay(500) - - val verifiedIssue = provider.queryIssues( - repository = repo, - query = IssueQuery( - state = IssueState.Open, - limit = 100, - ), - ).getOrNull()?.find { it.number == issueNumber } - - val finalStatus = IssueWorkflowStatus.fromLabels(verifiedIssue?.labels ?: emptyList()) - - if (finalStatus != IssueWorkflowStatus.CLAIMED) { - return Result.failure( - IllegalStateException("Race condition: another agent claimed the issue"), - ) - } - - return Result.success(Unit) - } catch (e: Exception) { - return Result.failure(e) - } - } - - /** - * Execute the full workflow on a claimed issue. - * - * This method orchestrates the complete issue-to-PR pipeline: - * 1. Update status to IN_PROGRESS - * 2. Create implementation plan - * 3. Execute plan steps (code, branch, commit, push, PR) - * 4. Update status to IN_REVIEW (if PR created) - * 5. Mark as BLOCKED on errors - * - * The implementation uses the AgentReasoning infrastructure to generate - * and execute a multi-step plan. Each step can be: - * - Code writing - * - Git operations (branch, stage, commit, push) - * - PR creation - * - * Status updates are handled safely - failures to update status do not - * block the workflow. - * - * @param issue The issue to work on (must already be claimed) - * @return Success with message if PR created, Failure with error otherwise - */ - suspend fun workOnIssue(issue: ExistingIssue): Result { - try { - // 1. Update to IN_PROGRESS - updateIssueStatusSafely( - issueNumber = issue.number, - status = IssueWorkflowStatus.IN_PROGRESS, - comment = "Starting implementation", - ) - - // 2. Create task from issue - val task = Task.CodeChange( - id = "issue-${issue.number}", - status = TaskStatus.Pending, - description = buildString { - appendLine("# ${issue.title}") - appendLine() - appendLine(issue.body) - appendLine() - appendLine("Issue: ${issue.url}") - appendLine() - appendLine("**Requirements:**") - appendLine("- Implement the feature/fix described above") - appendLine("- Create a feature branch") - appendLine("- Write tests if applicable") - appendLine("- Commit with conventional commit message") - appendLine("- Push to remote") - appendLine("- Create PR with 'Closes #${issue.number}'") - }, - ) - - // 3. Execute task with reasoning (plan generation + execution) - val outcome = executeTaskWithReasoning(task) - - // 4. Check if execution succeeded - when (outcome) { - is Outcome.Success -> { - // Execution succeeded - PR should have been created - // Status should already be updated to IN_REVIEW by executeGitCreatePRStep - return Result.success( - "Issue #${issue.number} completed successfully. " + - "PR created and ready for review.", - ) - } - - is Outcome.Failure -> { - // Execution failed - updateIssueStatusSafely( - issueNumber = issue.number, - status = IssueWorkflowStatus.BLOCKED, - comment = "Execution failed: ${outcome.id}", - ) - return Result.failure( - Exception("Execution failed: ${outcome.id}"), - ) - } - - else -> { - // Other outcome (e.g., Blank) - updateIssueStatusSafely( - issueNumber = issue.number, - status = IssueWorkflowStatus.BLOCKED, - comment = "Unexpected outcome: ${outcome::class.simpleName}", - ) - return Result.failure( - Exception("Unexpected outcome: ${outcome::class.simpleName}"), - ) - } - } - } catch (e: Exception) { - // Mark as blocked on any error - updateIssueStatusSafely( - issueNumber = issue.number, - status = IssueWorkflowStatus.BLOCKED, - comment = "Error: ${e.message}", - ) - return Result.failure(e) - } - } - - private fun createTicketForTask(task: Task.CodeChange): Ticket { - val now = Clock.System.now() - return Ticket( - id = "ticket-${task.id}", - title = "Execute task: ${task.id}", - description = task.description, - type = TicketType.TASK, - priority = TicketPriority.MEDIUM, - status = TicketStatus.InProgress, - assignedAgentId = when (val assignedTo = task.assignedTo) { - is AssignedTo.Agent -> assignedTo.agentId - else -> id - }, - createdByAgentId = id, - createdAt = now, - updatedAt = now, - dueDate = null, - ) - } - - private fun createTaskFailureOutcome(task: Task, reason: String): Outcome { - val now = Clock.System.now() - return ExecutionOutcome.NoChanges.Failure( - executorId = executor.id, - ticketId = "ticket-${task.id}", - taskId = task.id, - executionStartTimestamp = now, - executionEndTimestamp = now, - message = reason, - ) - } - - // ======================================================================== - // Issue Discovery for Perception - // ======================================================================== - - /** - * Query GitHub for issues assigned to this agent. - * - * Uses the IssueTrackerProvider to find open issues that are assigned - * to this agent (or its associated GitHub username). - * - * @return List of assigned issues, or empty list if provider is unavailable - */ - internal suspend fun queryAssignedIssues(): List { - val provider = issueTrackerProvider ?: return emptyList() - val repo = repository ?: return emptyList() - - return provider.queryIssues( - repository = repo, - query = IssueQuery( - state = IssueState.Open, - assignee = "CodeWriterAgent", // TODO: Map to actual GitHub username - labels = emptyList(), - limit = 20, - ), - ).getOrElse { emptyList() } - } - - /** - * Query GitHub for unassigned issues matching this agent's capabilities. - * - * Finds open issues labeled with "code" or "task" that are not yet - * assigned, making them available for this agent to claim. - * - * @return List of available issues, or empty list if provider is unavailable - */ - suspend fun queryAvailableIssues(): List { - val provider = issueTrackerProvider ?: return emptyList() - val repo = repository ?: return emptyList() - - return provider.queryIssues( - repository = repo, - query = IssueQuery( - state = IssueState.Open, - assignee = null, // Unassigned issues only - labels = listOf("code", "task"), // Issues matching our skills - limit = 10, - ), - ).getOrElse { emptyList() } - } - - // ======================================================================== - // Context Builders - Agent-specific customizations - // ======================================================================== - - internal suspend fun buildPerceptionContext(state: AgentState): String { - val codeState = state as? CodeState - val currentMemory = state.getCurrentMemory() - val pastMemory = state.getPastMemory() - val currentTask = currentMemory.task - val currentOutcome = currentMemory.outcome - - // Query issues from GitHub (if provider is available) - val assignedIssues = queryAssignedIssues() - val availableIssues = queryAvailableIssues() - - return PerceptionContextBuilder() - .header("CodeWriterAgent State Analysis") - .section("Current Task") { - when (currentTask) { - is Task.CodeChange -> { - field("Type", "Code Change") - field("ID", currentTask.id) - field("Status", currentTask.status) - field("Description", currentTask.description) - currentTask.assignedTo?.let { field("Assigned To", it) } - } - is MeetingTask.AgendaItem -> { - field("Type", "Meeting Agenda Item") - field("ID", currentTask.id) - field("Status", currentTask.status) - field("Title", currentTask.title) - currentTask.description?.let { field("Description", it) } - } - is TicketTask.CompleteSubticket -> { - field("Type", "Complete Subticket") - field("ID", currentTask.id) - field("Status", currentTask.status) - } - is Task.Blank -> { - line("No active task") - } - else -> { - field("Type", currentTask::class.simpleName) - field("ID", currentTask.id) - field("Status", currentTask.status) - } - } - } - .section("Current Outcome") { - when (currentOutcome) { - is Outcome.Success -> line("Status: ✓ Success") - is Outcome.Failure -> line("Status: ✗ Failure") - is Outcome.Blank -> line("Status: No outcome yet") - else -> line("Status: ${currentOutcome::class.simpleName}") - } - } - .sectionIf(pastMemory.tasks.isNotEmpty(), "Past Tasks") { - line("${pastMemory.tasks.size} completed") - } - .sectionIf(pastMemory.outcomes.isNotEmpty(), "Past Outcomes") { - line("${pastMemory.outcomes.size} recorded") - } - .sectionIf(pastMemory.knowledgeFromOutcomes.isNotEmpty(), "Learned Knowledge") { - pastMemory.knowledgeFromOutcomes.takeLast(3).forEach { knowledge -> - line("- Approach: ${knowledge.approach}") - line(" Learnings: ${knowledge.learnings}") - } - } - .sectionIf(assignedIssues.isNotEmpty(), "Assigned Issues") { - assignedIssues.forEach { issue -> - line("- #${issue.number}: ${issue.title}") - line(" URL: ${issue.url}") - line(" Labels: ${issue.labels.joinToString(", ")}") - if (issue.body.isNotBlank()) { - val preview = issue.body.take(100).replace("\n", " ") - line(" Description: $preview${if (issue.body.length > 100) "..." else ""}") - } - } - } - .sectionIf(availableIssues.isNotEmpty(), "Available Issues (Unassigned)") { - availableIssues.take(5).forEach { issue -> - line("- #${issue.number}: ${issue.title}") - line(" URL: ${issue.url}") - line(" Labels: ${issue.labels.joinToString(", ")}") - } - if (availableIssues.size > 5) { - line("... and ${availableIssues.size - 5} more available") - } - } - .section("Available Tools") { - requiredTools.forEach { tool -> - line("- ${tool.id}: ${tool.description}") - } - } - .build() - } - - private fun buildPlanningPrompt(task: Task, ideas: List): String = buildString { - appendLine("You are the planning module of an autonomous code-writing agent.") - appendLine() - appendLine("Task: ${extractTaskDescription(task)}") - appendLine() - if (ideas.isNotEmpty()) { - appendLine("Insights from Perception:") - ideas.forEach { idea -> - appendLine("${idea.name}:") - appendLine(idea.description) - appendLine() - } - } - appendLine("Available Tools:") - requiredTools.forEach { tool -> - appendLine("- ${tool.id}: ${tool.description}") - } - appendLine() - - // Check if we're working on a GitHub issue - val hasAssignedIssues = ideas.any { idea -> - idea.description.contains("Assigned Issues") || - idea.name.contains("issue", ignoreCase = true) - } - - if (hasAssignedIssues) { - appendLine("IMPORTANT: This task is related to a GitHub issue.") - appendLine() - appendLine("Your plan should follow the complete issue-to-PR workflow:") - appendLine("1. Analyze the issue requirements") - appendLine("2. Break down the implementation into concrete code changes") - appendLine("3. Implement each code change (write/modify files)") - appendLine("4. Ensure code quality and testing") - appendLine() - appendLine("Git operations (branch creation, commits, PRs) will be handled automatically.") - appendLine("Focus your plan on the CODE CHANGES needed to implement the issue.") - appendLine() - } - - appendLine("Create a step-by-step plan where each step is a concrete task that can be executed.") - appendLine("For simple tasks, create a 1-2 step plan.") - appendLine("For complex tasks, break down into logical phases (3-5 steps typically).") - - if (hasAssignedIssues) { - appendLine("For issue-based tasks, include:") - appendLine("- Analysis/research steps if requirements are unclear") - appendLine("- Specific code changes (create/modify specific files)") - appendLine("- Testing steps to verify the implementation") - } - - appendLine() - appendLine("Format your response as a JSON object:") - appendLine("""{"steps": [{"description": "...",""") - appendLine(""" "toolToUse": "write_code_file|read_code_file|null",""") - appendLine(""" "requiresPreviousStep": true/false}],""") - appendLine(""" "estimatedComplexity": 1-10}""") - } - - private fun buildOutcomeContext(outcomes: List): String = buildString { - appendLine("=== Code Execution Outcome Analysis ===") - appendLine() - val successCount = outcomes.count { it is Outcome.Success } - val failedCount = outcomes.count { it is Outcome.Failure } - appendLine("Total: ${outcomes.size}, Success: $successCount, Failed: $failedCount") - appendLine() - - outcomes.forEachIndexed { i, outcome -> - when (outcome) { - is ExecutionOutcome.CodeChanged.Success -> { - appendLine("${i + 1}. ✓ Code Changed Successfully") - appendLine(" Files: ${outcome.changedFiles.size}") - outcome.changedFiles.take(3).forEach { file -> - appendLine(" - $file") - } - if (outcome.changedFiles.size > 3) { - appendLine(" ... and ${outcome.changedFiles.size - 3} more") - } - } - is ExecutionOutcome.CodeChanged.Failure -> { - appendLine("${i + 1}. ✗ Code Change Failed") - appendLine(" Error: ${outcome.error}") - } - is ExecutionOutcome.CodeReading.Success -> { - appendLine("${i + 1}. ✓ Code Read Successfully") - appendLine(" Files: ${outcome.readFiles.size}") - } - is ExecutionOutcome.CodeReading.Failure -> { - appendLine("${i + 1}. ✗ Code Reading Failed") - appendLine(" Error: ${outcome.error}") - } - else -> { - appendLine("${i + 1}. ${outcome::class.simpleName}") - } - } - } - } - - private fun extractTaskDescription(task: Task): String = when (task) { - is Task.CodeChange -> task.description - is MeetingTask.AgendaItem -> task.title - is TicketTask.CompleteSubticket -> "Complete subticket ${task.id}" - is PMTask -> "PM task ${task.id}" - is Task.Blank -> "" - } - - // ======================================================================== - // Utility Functions - // ======================================================================== - - /** - * Helper for coroutine-based file writing with callback. - */ - protected fun writeCodeFile( - executionRequest: ExecutionRequest, - onCodeSubmittedOutcome: (Outcome) -> Unit, - ) { - coroutineScope.launch { - val outcome = toolWriteCodeFile.execute(executionRequest) - onCodeSubmittedOutcome(outcome) - } - } - - companion object Companion { - val SYSTEM_PROMPT = CodePrompts.SYSTEM_PROMPT - } -} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/ProductAgent.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/ProductAgent.kt deleted file mode 100644 index 08f957a5..00000000 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/ProductAgent.kt +++ /dev/null @@ -1,420 +0,0 @@ -package link.socket.ampere.agents.definition - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withTimeout -import kotlinx.datetime.Clock -import link.socket.ampere.agents.config.AgentConfiguration -import link.socket.ampere.agents.definition.product.PlanningInsights -import link.socket.ampere.agents.definition.product.ProductPrompts -import link.socket.ampere.agents.definition.product.ProductState -import link.socket.ampere.agents.domain.cognition.CognitiveAffinity -import link.socket.ampere.agents.domain.knowledge.Knowledge -import link.socket.ampere.agents.domain.memory.AgentMemoryService -import link.socket.ampere.agents.domain.memory.KnowledgeWithScore -import link.socket.ampere.agents.domain.outcome.ExecutionOutcome -import link.socket.ampere.agents.domain.outcome.Outcome -import link.socket.ampere.agents.domain.reasoning.AgentReasoning -import link.socket.ampere.agents.domain.reasoning.DefaultTaskFactory -import link.socket.ampere.agents.domain.reasoning.Idea -import link.socket.ampere.agents.domain.reasoning.Perception -import link.socket.ampere.agents.domain.reasoning.Plan -import link.socket.ampere.agents.domain.status.TaskStatus -import link.socket.ampere.agents.domain.status.TicketStatus -import link.socket.ampere.agents.domain.task.Task -import link.socket.ampere.agents.events.api.AgentEventApi -import link.socket.ampere.agents.events.tickets.AgentWorkload -import link.socket.ampere.agents.events.tickets.BacklogSummary -import link.socket.ampere.agents.events.tickets.TicketOrchestrator -import link.socket.ampere.agents.events.utils.generateUUID -import link.socket.ampere.agents.execution.executor.Executor -import link.socket.ampere.agents.execution.executor.FunctionExecutor -import link.socket.ampere.agents.execution.request.ExecutionRequest -import link.socket.ampere.agents.execution.tools.Tool -import link.socket.ampere.util.ioDispatcher -import link.socket.ampere.util.runBlockingCompat - -/** - * Product Manager Agent responsible for breaking down features into tasks - * and coordinating implementation efforts. - * - * Enhanced with episodic memory-learns which decomposition strategies - * lead to successful implementations versus which create confusion or rework. - * - * Uses the unified AgentReasoning infrastructure for all cognitive operations. - */ -class ProductAgent( - override val agentConfiguration: AgentConfiguration, - private val ticketOrchestrator: TicketOrchestrator, - private val coroutineScope: CoroutineScope? = null, - override val initialState: ProductState = ProductState.blank, - private val executor: Executor = FunctionExecutor.create(), - memoryServiceFactory: ((AgentId) -> AgentMemoryService)? = null, - private val eventApiOverride: AgentEventApi? = null, - private val observabilityScope: CoroutineScope = CoroutineScope(Dispatchers.Default), - private val agentId: AgentId = generateUUID("ProductManagerAgent"), -) : ObservableAgent(eventApiOverride, observabilityScope) { - - override val id: AgentId = agentId - - override val memoryService: AgentMemoryService? = memoryServiceFactory?.invoke(id) - - override val requiredTools: Set> = emptySet() - - /** - * ProductAgent uses INTEGRATIVE cognitive affinity. - * - * This shapes the agent to understand how features fit into the product, - * connect user needs to implementation - ideal for product planning - * and feature decomposition. - */ - override val affinity: CognitiveAffinity = CognitiveAffinity.INTEGRATIVE - - // ======================================================================== - // Unified Reasoning - All cognitive logic in one place - // ======================================================================== - - private val reasoning = AgentReasoning.create(agentConfiguration, id, eventApiOverride) { - agentRole = "Product Manager" - availableTools = requiredTools - this.executor = this@ProductAgent.executor - - perception { - contextBuilder = { state -> ProductPrompts.perceptionContext(state as ProductState) } - } - - planning { - taskFactory = DefaultTaskFactory - customPrompt = { task, ideas, tools, knowledge -> - val insights = PlanningInsights.fromKnowledge(knowledge) - ProductPrompts.planning(task, ideas, insights) - } - } - - execution { - // No custom strategies yet - ready for product-specific tools - } - - evaluation { - contextBuilder = { outcomes -> ProductPrompts.outcomeContext(outcomes) } - } - - knowledge { - extractor = { outcome, task, plan -> - extractProductKnowledge(outcome, task, plan) - } - } - } - - // ======================================================================== - // PROPEL Cognitive Functions - Delegate to reasoning infrastructure - // ======================================================================== - - override val runLLMToEvaluatePerception: (perception: Perception) -> Idea = - { perception -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.evaluatePerception(perception) - } - } - } - - override val runLLMToPlan: (task: Task, ideas: List) -> Plan = - { task, ideas -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.generatePlan(task, ideas) - } - } - } - - override val runLLMToExecuteTask: (task: Task) -> Outcome = - { task -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - executeTaskWithReasoning(task) - } - } - } - - override val runLLMToExecuteTool: (tool: Tool<*>, request: ExecutionRequest<*>) -> ExecutionOutcome = - { tool, request -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.executeTool(tool, request) - } - } - } - - override val runLLMToEvaluateOutcomes: (outcomes: List) -> Idea = - { outcomes -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.evaluateOutcomes(outcomes, memoryService).summaryIdea - } - } - } - - override fun extractKnowledgeFromOutcome( - outcome: Outcome, - task: Task, - plan: Plan, - ): Knowledge.FromOutcome = extractProductKnowledge(outcome, task, plan) - - override fun callLLM(prompt: String): String = - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.callLLM(prompt) - } - } - - // ======================================================================== - // State Management - Fresh state from TicketOrchestrator - // ======================================================================== - - /** - * Overrides getCurrentState to return fresh state from ticket orchestrator. - */ - override fun getCurrentState(): ProductState = runBlockingCompat { - getUpdatedAgentState() - } - - /** - * Overrides perceiveState to fetch fresh state from ticket orchestrator. - */ - override suspend fun perceiveState( - currentState: ProductState, - vararg newIdeas: Idea, - ): Perception { - val freshState = getUpdatedAgentState() - - val ideas = mutableListOf() - ideas.add(Idea(name = "PM Agent Perception State")) - ideas.add(Idea(name = "Backlog Summary")) - ideas.add(Idea(name = "Total Tickets: ${freshState.backlogSummary.totalTickets}")) - - if (freshState.blockedTickets.isNotEmpty()) { - ideas.add(Idea(name = "BLOCKED TICKETS")) - freshState.blockedTickets.forEach { ticket -> - ideas.add(Idea(name = ticket.title)) - } - } - - if (freshState.overdueTickets.isNotEmpty()) { - ideas.add(Idea(name = "OVERDUE TICKETS")) - } - - return Perception( - id = generateUUID(id), - ideas = ideas, - currentState = freshState, - timestamp = Clock.System.now(), - ) - } - - /** - * Custom planning that incorporates learned insights from past knowledge. - */ - override suspend fun determinePlanForTask( - task: Task, - vararg ideas: Idea, - relevantKnowledge: List, - ): Plan { - val insights = PlanningInsights.fromKnowledge(relevantKnowledge) - val planTasks = mutableListOf() - var estimatedComplexity = 5 - - when (task) { - is Task.CodeChange -> { - if (insights.testFirstSuccessRate > 0.7) { - planTasks.add( - Task.CodeChange( - id = generateUUID("${task.id}-test-spec"), - status = TaskStatus.Pending, - description = "Define test specifications before implementation " + - "(Past knowledge shows ${(insights.testFirstSuccessRate * 100).toInt()}% " + - "success rate: ${insights.testFirstLearnings.take(100)})", - assignedTo = task.assignedTo, - ), - ) - } - - insights.commonFailures.forEach { (failurePoint, learnings) -> - planTasks.add( - Task.CodeChange( - id = generateUUID("${task.id}-validate-${failurePoint.hashCode()}"), - status = TaskStatus.Pending, - description = "Validate against known failure pattern: $failurePoint " + - "(Past learnings: ${learnings.take(100)})", - assignedTo = task.assignedTo, - ), - ) - } - - val optimalTasks = insights.optimalTaskCount ?: 5 - planTasks.add( - Task.CodeChange( - id = generateUUID("${task.id}-implement"), - status = TaskStatus.Pending, - description = "Implement ${task.description} in approximately $optimalTasks subtasks " + - "(Based on past success patterns: ${insights.decompositionLearnings.take(100)})", - assignedTo = task.assignedTo, - ), - ) - - estimatedComplexity = when { - insights.commonFailures.isNotEmpty() -> 8 - insights.testFirstSuccessRate > 0.7 -> 4 - else -> 5 - } - } - else -> { - planTasks.add(task) - } - } - - return Plan.ForTask( - task = task, - tasks = planTasks, - estimatedComplexity = estimatedComplexity, - ) - } - - // ======================================================================== - // Task Execution - // ======================================================================== - - private suspend fun executeTaskWithReasoning(task: Task): Outcome { - if (task is Task.Blank) { - return Outcome.blank - } - - val plan = reasoning.generatePlan(task, emptyList()) - return reasoning.executePlan(plan) { step, _ -> - link.socket.ampere.agents.domain.reasoning.StepResult.success( - description = "Execute step: ${step.id}", - details = "Step completed", - ) - }.outcome - } - - // ======================================================================== - // State Fetching - // ======================================================================== - - private suspend fun getUpdatedAgentState( - agentIds: List = emptyList(), - deadlineDaysAhead: Int = 7, - ): ProductState { - val backlogSummary = ticketOrchestrator.getBacklogSummary() - .getOrElse { BacklogSummary.empty() } - - val allTickets = ticketOrchestrator.getAllTickets().getOrElse { emptyList() } - val discoveredAgentIds = if (agentIds.isEmpty()) { - allTickets.mapNotNull { it.assignedAgentId }.distinct() - } else { - agentIds - } - - val agentWorkloads = discoveredAgentIds.associateWith { agentId -> - ticketOrchestrator.getAgentWorkload(agentId) - .getOrElse { AgentWorkload.empty(agentId) } - } - - val upcomingDeadlines = ticketOrchestrator.getUpcomingDeadlines(deadlineDaysAhead) - .getOrElse { emptyList() } - - val blockedTickets = agentWorkloads.values - .flatMap { it.assignedTickets } - .filter { it.status == TicketStatus.Blocked } - .distinctBy { it.id } - - val now = Clock.System.now() - val overdueTickets = agentWorkloads.values - .flatMap { it.assignedTickets } - .filter { ticket -> - ticket.dueDate != null && - ticket.dueDate < now && - ticket.status != TicketStatus.Done - } - .distinctBy { it.id } - - return ProductState( - outcome = Outcome.blank, - task = Task.Blank, - plan = Plan.blank, - backlogSummary = backlogSummary, - agentWorkloads = agentWorkloads, - upcomingDeadlines = upcomingDeadlines, - blockedTickets = blockedTickets, - overdueTickets = overdueTickets, - ) - } - - // ======================================================================== - // Knowledge Extraction - // ======================================================================== - - private fun extractProductKnowledge( - outcome: Outcome, - task: Task, - plan: Plan, - ): Knowledge.FromOutcome { - val taskDescription = when (task) { - is Task.CodeChange -> task.description - else -> "Generic task ${task.id}" - } - - val approachDescription = buildString { - append("Decomposed '$taskDescription' into ${plan.tasks.size} tasks. ") - if (plan.tasks.any { t -> - when (t) { - is Task.CodeChange -> t.description.contains("test", ignoreCase = true) - else -> false - } - } - ) { - append("Used test-first approach. ") - } - append("Complexity: ${plan.estimatedComplexity}") - } - - val learnings = buildString { - when (outcome) { - is Outcome.Success -> { - append("Success: ${plan.tasks.size}-task decomposition worked well. ") - if (plan.tasks.any { t -> - when (t) { - is Task.CodeChange -> t.description.contains("test", ignoreCase = true) - else -> false - } - } - ) { - append("Test-first approach prevented issues. ") - } - append("Recommend similar decomposition for future tasks.") - } - is Outcome.Failure -> { - append("Failure occurred during execution. ") - append("Recommend adjusting decomposition strategy or adding validation steps. ") - append("Consider breaking into ${plan.tasks.size + 2} tasks instead.") - } - else -> { - append("Partial completion with ${plan.tasks.size} tasks. ") - append("May need to refine task granularity.") - } - } - } - - return Knowledge.FromOutcome( - outcomeId = outcome.id, - approach = approachDescription, - learnings = learnings, - timestamp = Clock.System.now(), - ) - } - - companion object Companion { - val SYSTEM_PROMPT = ProductPrompts.SYSTEM_PROMPT - } -} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/ProjectAgent.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/ProjectAgent.kt deleted file mode 100644 index 8088618d..00000000 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/ProjectAgent.kt +++ /dev/null @@ -1,429 +0,0 @@ -package link.socket.ampere.agents.definition - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withTimeout -import link.socket.ampere.agents.config.AgentConfiguration -import link.socket.ampere.agents.definition.project.ProjectParams -import link.socket.ampere.agents.definition.project.ProjectPrompts -import link.socket.ampere.agents.definition.project.ProjectState -import link.socket.ampere.agents.domain.cognition.CognitiveAffinity -import link.socket.ampere.agents.domain.knowledge.Knowledge -import link.socket.ampere.agents.domain.outcome.ExecutionOutcome -import link.socket.ampere.agents.domain.outcome.Outcome -import link.socket.ampere.agents.domain.reasoning.AgentReasoning -import link.socket.ampere.agents.domain.reasoning.Idea -import link.socket.ampere.agents.domain.reasoning.KnowledgeExtractor -import link.socket.ampere.agents.domain.reasoning.Perception -import link.socket.ampere.agents.domain.reasoning.PerceptionContextBuilder -import link.socket.ampere.agents.domain.reasoning.Plan -import link.socket.ampere.agents.domain.reasoning.StepContext -import link.socket.ampere.agents.domain.reasoning.StepResult -import link.socket.ampere.agents.domain.state.AgentState -import link.socket.ampere.agents.domain.status.TaskStatus -import link.socket.ampere.agents.domain.status.TicketStatus -import link.socket.ampere.agents.domain.task.PMTask -import link.socket.ampere.agents.domain.task.Task -import link.socket.ampere.agents.events.api.AgentEventApi -import link.socket.ampere.agents.events.utils.generateUUID -import link.socket.ampere.agents.execution.executor.Executor -import link.socket.ampere.agents.execution.executor.FunctionExecutor -import link.socket.ampere.agents.execution.request.ExecutionContext -import link.socket.ampere.agents.execution.request.ExecutionRequest -import link.socket.ampere.agents.execution.tools.Tool -import link.socket.ampere.agents.execution.tools.issue.BatchIssueCreateRequest -import link.socket.ampere.util.ioDispatcher -import link.socket.ampere.util.runBlockingCompat - -/** - * Project Manager Agent - The Executive Function of AMPERE - * - * Acts as the "prefrontal cortex" of the multi-agent system, responsible for: - * - Decomposing high-level goals into structured work breakdowns - * - Creating issues in external systems (GitHub, JIRA, etc.) - * - Assigning tasks to appropriate agents based on capabilities - * - Monitoring task progress through the event stream - * - Facilitating coordination when agents are blocked - * - Escalating to humans when decisions exceed agent authority - * - * Uses the unified AgentReasoning infrastructure for all cognitive operations. - */ -open class ProjectAgent( - override val agentConfiguration: AgentConfiguration, - private val toolCreateIssues: Tool, - private val toolAskHuman: Tool, - private val coroutineScope: CoroutineScope, - override val initialState: AgentState = ProjectState.blank, - private val executor: Executor = FunctionExecutor.create(), - memoryServiceFactory: ((AgentId) -> link.socket.ampere.agents.domain.memory.AgentMemoryService)? = null, - reasoningOverride: AgentReasoning? = null, - private val eventApiOverride: AgentEventApi? = null, - private val observabilityScope: CoroutineScope = CoroutineScope(Dispatchers.Default), - private val agentId: AgentId = generateUUID("ProjectManagerAgent"), -) : ObservableAgent(eventApiOverride, observabilityScope) { - - override val id: AgentId = agentId - - override val memoryService: link.socket.ampere.agents.domain.memory.AgentMemoryService? = - memoryServiceFactory?.invoke(id) - - override val requiredTools: Set> = setOf(toolCreateIssues, toolAskHuman) - - /** - * ProjectAgent uses INTEGRATIVE cognitive affinity. - * - * This shapes the agent to understand the whole system, connect parts, - * and bridge perspectives - ideal for project management, coordination, - * and planning. - */ - override val affinity: CognitiveAffinity = CognitiveAffinity.INTEGRATIVE - - // ======================================================================== - // Unified Reasoning - All cognitive logic in one place - // ======================================================================== - - private val reasoning: AgentReasoning = reasoningOverride ?: AgentReasoning.create( - agentConfiguration, - id, - eventApiOverride, - ) { - agentRole = "Project Manager" - availableTools = requiredTools - this.executor = this@ProjectAgent.executor - - perception { - contextBuilder = { state -> buildPerceptionContext(state) } - } - - planning { - taskFactory = PMTaskFactory - customPrompt = { task, ideas, tools, knowledge -> - buildPlanningPrompt(task, ideas) - } - } - - execution { - registerStrategy( - toolCreateIssues.id, - ProjectParams.IssueCreation( - repository = "owner/repo", - availableAgents = emptyList(), - existingIssues = emptyList(), - ), - ) - registerStrategy(toolAskHuman.id, ProjectParams.HumanEscalation("Project Manager")) - } - - evaluation { - contextBuilder = { outcomes -> buildOutcomeContext(outcomes) } - } - - knowledge { - extractor = { outcome, task, plan -> - KnowledgeExtractor.extract(outcome, task, plan) { - approach { - prefix("PM Task") - taskType(task) - planSize(plan) - } - learnings { - fromOutcome(outcome) - } - } - } - } - } - - // ======================================================================== - // PROPEL Cognitive Functions - Delegate to reasoning infrastructure - // ======================================================================== - - override val runLLMToEvaluatePerception: (perception: Perception) -> Idea = - { perception -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.evaluatePerception(perception) - } - } - } - - override val runLLMToPlan: (task: Task, ideas: List) -> Plan = - { task, ideas -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.generatePlan(task, ideas) - } - } - } - - override val runLLMToExecuteTask: (task: Task) -> Outcome = - { task -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - executeTaskWithReasoning(task) - } - } - } - - override val runLLMToExecuteTool: (tool: Tool<*>, request: ExecutionRequest<*>) -> ExecutionOutcome = - { tool, request -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.executeTool(tool, request) - } - } - } - - override val runLLMToEvaluateOutcomes: (outcomes: List) -> Idea = - { outcomes -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.evaluateOutcomes(outcomes, memoryService).summaryIdea - } - } - } - - override fun extractKnowledgeFromOutcome( - outcome: Outcome, - task: Task, - plan: Plan, - ): Knowledge.FromOutcome = reasoning.extractKnowledge(outcome, task, plan) - - override fun callLLM(prompt: String): String = - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.callLLM(prompt) - } - } - - // ======================================================================== - // Task Execution - Uses PlanExecutor for orchestration - // ======================================================================== - - private suspend fun executeTaskWithReasoning(task: Task): Outcome { - val plan = reasoning.generatePlan(task, emptyList()) - return reasoning.executePlan(plan) { step, context -> - executeStep(step, context) - }.outcome - } - - private suspend fun executeStep(step: Task, context: StepContext): StepResult { - return when (step) { - is PMTask.CreateIssues -> { - val outcome = reasoning.executeTool( - toolCreateIssues, - createExecutionRequest(step), - ) - when (outcome) { - is ExecutionOutcome.IssueManagement.Success -> { - StepResult.success( - description = "Create ${outcome.response.created.size} issues", - details = outcome.response.created.joinToString { "#${it.issueNumber}" }, - contextUpdates = mapOf( - "created_issues" to outcome.response.created.associate { - it.localId to it.issueNumber - }, - ), - ) - } - is ExecutionOutcome.IssueManagement.Failure -> { - StepResult.failure( - description = "Create issues", - error = outcome.error.message, - isCritical = true, - ) - } - else -> StepResult.failure( - description = "Create issues", - error = "Unexpected outcome: ${outcome::class.simpleName}", - isCritical = true, - ) - } - } - is PMTask.AssignTask -> { - val issueMap: Map? = context.get("created_issues") - val issueNumber = issueMap?.get(step.taskLocalId) - StepResult.success( - description = "Assign task ${step.taskLocalId} to ${step.agentId}", - details = buildString { - append("Assigned task ${step.taskLocalId}") - issueNumber?.let { append(" (#$it)") } - append(" to ${step.agentId}: ${step.reasoning}") - }, - ) - } - is PMTask.StartMonitoring -> { - val issueMap: Map? = context.get("created_issues") - val epicNumber = issueMap?.get(step.epicLocalId) - StepResult.success( - description = "Start monitoring epic ${step.epicLocalId}", - details = buildString { - append("Started monitoring epic ${step.epicLocalId}") - epicNumber?.let { append(" (#$it)") } - append(" with ${step.tasks.size} tasks") - }, - ) - } - else -> StepResult.skip( - description = "Unknown step", - reason = "Step type ${step::class.simpleName} not implemented", - ) - } - } - - private fun createExecutionRequest(step: PMTask.CreateIssues): ExecutionRequest { - val now = kotlinx.datetime.Clock.System.now() - val ticket = link.socket.ampere.agents.events.tickets.Ticket( - id = "pm-ticket-${step.id}", - title = "PM Task: ${step.id}", - description = "Project Manager task execution", - type = link.socket.ampere.agents.events.tickets.TicketType.TASK, - priority = link.socket.ampere.agents.events.tickets.TicketPriority.MEDIUM, - status = TicketStatus.InProgress, - assignedAgentId = id, - createdByAgentId = id, - createdAt = now, - updatedAt = now, - dueDate = null, - ) - return ExecutionRequest( - context = ExecutionContext.IssueManagement( - executorId = id, - ticket = ticket, - task = step, - instructions = "Create issues for work breakdown", - issueRequest = step.issueRequest, - knowledgeFromPastMemory = emptyList(), - ), - constraints = link.socket.ampere.agents.execution.request.ExecutionConstraints( - requireTests = false, - requireLinting = false, - ), - ) - } - - // ======================================================================== - // Context Builders - Agent-specific customizations - // ======================================================================== - - private fun buildPerceptionContext(state: AgentState): String { - val pmState = state as? ProjectState ?: return "No PM state available" - return PerceptionContextBuilder() - .header("Project Manager State Analysis") - .sectionIf(pmState.activeGoals.isNotEmpty(), "Active Goals") { - pmState.activeGoals.forEach { goal -> - line("Goal: ${goal.description}") - field("Status", goal.status) - field("Priority", goal.priority) - } - } - .sectionIf(pmState.workBreakdowns.isNotEmpty(), "Work Breakdowns") { - pmState.workBreakdowns.forEach { breakdown -> - line("Epic: ${breakdown.epicTitle}") - field("Tasks", breakdown.tasks.size) - field("Completed", breakdown.tasks.count { it.status == "completed" }) - } - } - .sectionIf(pmState.blockedTasks.isNotEmpty(), "Blocked Tasks") { - pmState.blockedTasks.forEach { taskId -> line("- $taskId") } - } - .sectionIf(pmState.pendingEscalations.isNotEmpty(), "Pending Escalations") { - pmState.pendingEscalations.forEach { escalation -> - line("Decision: ${escalation.decision}") - field("Reason", escalation.reason) - } - } - .build() - } - - private fun buildPlanningPrompt(task: Task, ideas: List): String = buildString { - appendLine("You are the planning module of an autonomous Project Manager agent.") - appendLine() - appendLine("Goal: ${extractTaskDescription(task)}") - appendLine() - if (ideas.isNotEmpty()) { - appendLine("Insights:") - ideas.forEach { appendLine("- ${it.name}: ${it.description}") } - appendLine() - } - appendLine("Available Actions: Create Issues, Assign Tasks, Start Monitoring, Escalate to Human") - appendLine() - appendLine("Create a JSON plan with steps to accomplish the goal.") - appendLine( - """{"steps": [{"description": "...", "toolToUse": """ + - """"create_issues|assign_task|start_monitoring|ask_human", """ + - """"requiresPreviousStep": true/false}], "estimatedComplexity": 1-10}""", - ) - } - - private fun buildOutcomeContext(outcomes: List): String = buildString { - appendLine("=== PM Outcome Analysis ===") - appendLine( - "Total: ${outcomes.size}, " + - "Success: ${outcomes.count { it is Outcome.Success }}, " + - "Failed: ${outcomes.count { it is Outcome.Failure }}", - ) - outcomes.forEachIndexed { i, outcome -> - when (outcome) { - is ExecutionOutcome.IssueManagement.Success -> - appendLine("${i + 1}. ✓ Created ${outcome.response.created.size} issues") - is ExecutionOutcome.IssueManagement.Failure -> - appendLine("${i + 1}. ✗ ${outcome.error.message}") - else -> - appendLine("${i + 1}. ${outcome::class.simpleName}") - } - } - } - - private fun extractTaskDescription(task: Task): String = when (task) { - is PMTask.DecomposeGoal -> task.goal - is PMTask.AssessProgress -> "Assess progress on epic ${task.epicId}" - is PMTask.CreateIssues -> "Create ${task.issueRequest.issues.size} issues" - is PMTask.AssignTask -> "Assign task ${task.taskLocalId}" - is PMTask.StartMonitoring -> "Monitor epic ${task.epicLocalId}" - is Task.CodeChange -> task.description - is Task.Blank -> "" - else -> "Task ${task.id}" - } - - companion object Companion { - val SYSTEM_PROMPT = ProjectPrompts.SYSTEM_PROMPT - } -} - -/** - * Task factory for PM-specific task types. - */ -private object PMTaskFactory : link.socket.ampere.agents.domain.reasoning.TaskFactory { - override fun create(id: String, description: String, toolId: String?, originalTask: Task): Task { - return when (toolId) { - "create_issues" -> PMTask.CreateIssues( - id = id, - status = TaskStatus.Pending, - issueRequest = BatchIssueCreateRequest( - repository = "owner/repo", - issues = emptyList(), - ), - ) - "assign_task" -> PMTask.AssignTask( - id = id, - status = TaskStatus.Pending, - taskLocalId = "pending", - agentId = "pending", - reasoning = description, - ) - "start_monitoring" -> PMTask.StartMonitoring( - id = id, - status = TaskStatus.Pending, - epicLocalId = "pending", - tasks = emptyList(), - ) - else -> Task.CodeChange( - id = id, - status = TaskStatus.Pending, - description = description, - assignedTo = null, - ) - } - } -} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/QualityAgent.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/QualityAgent.kt deleted file mode 100644 index bb0daf98..00000000 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/QualityAgent.kt +++ /dev/null @@ -1,355 +0,0 @@ -package link.socket.ampere.agents.definition - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withTimeout -import kotlinx.datetime.Clock -import link.socket.ampere.agents.config.AgentConfiguration -import link.socket.ampere.agents.definition.qa.QualityPrompts -import link.socket.ampere.agents.definition.qa.QualityState -import link.socket.ampere.agents.definition.qa.ValidationInsights -import link.socket.ampere.agents.domain.cognition.CognitiveAffinity -import link.socket.ampere.agents.domain.knowledge.Knowledge -import link.socket.ampere.agents.domain.memory.AgentMemoryService -import link.socket.ampere.agents.domain.memory.KnowledgeWithScore -import link.socket.ampere.agents.domain.outcome.ExecutionOutcome -import link.socket.ampere.agents.domain.outcome.Outcome -import link.socket.ampere.agents.domain.reasoning.AgentReasoning -import link.socket.ampere.agents.domain.reasoning.DefaultTaskFactory -import link.socket.ampere.agents.domain.reasoning.Idea -import link.socket.ampere.agents.domain.reasoning.Perception -import link.socket.ampere.agents.domain.reasoning.Plan -import link.socket.ampere.agents.domain.reasoning.StepResult -import link.socket.ampere.agents.domain.status.TaskStatus -import link.socket.ampere.agents.domain.task.Task -import link.socket.ampere.agents.events.api.AgentEventApi -import link.socket.ampere.agents.events.utils.generateUUID -import link.socket.ampere.agents.execution.executor.Executor -import link.socket.ampere.agents.execution.executor.FunctionExecutor -import link.socket.ampere.agents.execution.request.ExecutionRequest -import link.socket.ampere.agents.execution.tools.Tool -import link.socket.ampere.util.ioDispatcher -import link.socket.ampere.util.runBlockingCompat - -/** - * Quality Assurance Agent responsible for verifying code quality, correctness, - * and adherence to standards. - * - * Enhanced with episodic memory—learns which validation approaches catch - * the most issues and which types of problems are commonly missed. - * - * Uses the unified AgentReasoning infrastructure for all cognitive operations. - */ -class QualityAgent( - override val agentConfiguration: AgentConfiguration, - private val coroutineScope: CoroutineScope? = null, - override val initialState: QualityState = QualityState.blank, - private val executor: Executor = FunctionExecutor.create(), - memoryServiceFactory: ((AgentId) -> AgentMemoryService)? = null, - private val eventApiOverride: AgentEventApi? = null, - private val observabilityScope: CoroutineScope = CoroutineScope(Dispatchers.Default), - private val agentId: AgentId = generateUUID("QualityAssuranceAgent"), -) : ObservableAgent(eventApiOverride, observabilityScope) { - - override val id: AgentId = agentId - - override val memoryService: AgentMemoryService? = memoryServiceFactory?.invoke(id) - - override val requiredTools: Set> = emptySet() - - /** - * QualityAgent uses ANALYTICAL cognitive affinity. - * - * This shapes the agent to break down problems systematically, - * verify correctness, and trace logic - ideal for quality assurance, - * testing, and code review. - */ - override val affinity: CognitiveAffinity = CognitiveAffinity.ANALYTICAL - - // ======================================================================== - // Unified Reasoning - All cognitive logic in one place - // ======================================================================== - - private val reasoning = AgentReasoning.create(agentConfiguration, id, eventApiOverride) { - agentRole = "Quality Assurance" - availableTools = requiredTools - this.executor = this@QualityAgent.executor - - perception { - contextBuilder = { state -> QualityPrompts.perceptionContext(state as QualityState) } - } - - planning { - taskFactory = DefaultTaskFactory - customPrompt = { task, ideas, tools, knowledge -> - val insights = ValidationInsights.fromKnowledge(knowledge) - QualityPrompts.planning(task, ideas, insights) - } - } - - execution { - // No custom strategies yet - ready for QA-specific tools - } - - evaluation { - contextBuilder = { outcomes -> QualityPrompts.outcomeContext(outcomes) } - } - - knowledge { - extractor = { outcome, task, plan -> - extractQualityKnowledge(outcome, task, plan) - } - } - } - - // ======================================================================== - // PROPEL Cognitive Functions - Delegate to reasoning infrastructure - // ======================================================================== - - override val runLLMToEvaluatePerception: (perception: Perception) -> Idea = - { perception -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.evaluatePerception(perception) - } - } - } - - override val runLLMToPlan: (task: Task, ideas: List) -> Plan = - { task, ideas -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.generatePlan(task, ideas) - } - } - } - - override val runLLMToExecuteTask: (task: Task) -> Outcome = - { task -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - executeTaskWithReasoning(task) - } - } - } - - override val runLLMToExecuteTool: (tool: Tool<*>, request: ExecutionRequest<*>) -> ExecutionOutcome = - { tool, request -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.executeTool(tool, request) - } - } - } - - override val runLLMToEvaluateOutcomes: (outcomes: List) -> Idea = - { outcomes -> - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.evaluateOutcomes(outcomes, memoryService).summaryIdea - } - } - } - - override fun extractKnowledgeFromOutcome( - outcome: Outcome, - task: Task, - plan: Plan, - ): Knowledge.FromOutcome = extractQualityKnowledge(outcome, task, plan) - - override fun callLLM(prompt: String): String = - runBlockingCompat(ioDispatcher) { - withTimeout(60000) { - reasoning.callLLM(prompt) - } - } - - // ======================================================================== - // Custom Planning with Learned Validation Insights - // ======================================================================== - - /** - * Custom planning that incorporates learned insights from past knowledge. - * - * The QualityAgent learns patterns like: - * - Which validation checks catch the most issues - * - Which types of bugs are frequently missed - * - Effective validation order and prioritization - */ - override suspend fun determinePlanForTask( - task: Task, - vararg ideas: Idea, - relevantKnowledge: List, - ): Plan { - val insights = ValidationInsights.fromKnowledge(relevantKnowledge) - val planTasks = mutableListOf() - var estimatedComplexity = 5 - - when (task) { - is Task.CodeChange -> { - // Prioritize validation checks based on past effectiveness - insights.effectiveChecks.toList() - .sortedByDescending { it.second } - .forEach { (checkType, effectiveness) -> - planTasks.add( - Task.CodeChange( - id = generateUUID("${task.id}-validate-$checkType"), - status = TaskStatus.Pending, - description = "Run $checkType validation (${(effectiveness * 100).toInt()}% " + - "effectiveness from past experience)", - assignedTo = task.assignedTo, - ), - ) - } - - // Add extra validation for commonly missed issues - insights.commonlyMissedIssues.forEach { issueType -> - planTasks.add( - Task.CodeChange( - id = generateUUID("${task.id}-check-${issueType.hashCode()}"), - status = TaskStatus.Pending, - description = "Extra validation for commonly missed: $issueType", - assignedTo = task.assignedTo, - ), - ) - } - - // If no specific insights, add standard validation tasks - if (planTasks.isEmpty()) { - planTasks.add( - Task.CodeChange( - id = generateUUID("${task.id}-syntax"), - status = TaskStatus.Pending, - description = "Syntax and compilation validation for ${task.description}", - assignedTo = task.assignedTo, - ), - ) - planTasks.add( - Task.CodeChange( - id = generateUUID("${task.id}-style"), - status = TaskStatus.Pending, - description = "Code style and standards validation", - assignedTo = task.assignedTo, - ), - ) - planTasks.add( - Task.CodeChange( - id = generateUUID("${task.id}-logic"), - status = TaskStatus.Pending, - description = "Logic and correctness validation", - assignedTo = task.assignedTo, - ), - ) - } - - // Adjust complexity based on insights - estimatedComplexity = when { - insights.commonlyMissedIssues.size > 3 -> 8 - insights.effectiveChecks.values.average() > 0.8 -> 3 - else -> 5 - } - } - else -> { - planTasks.add(task) - } - } - - return Plan.ForTask( - task = task, - tasks = planTasks, - estimatedComplexity = estimatedComplexity, - ) - } - - // ======================================================================== - // Task Execution - // ======================================================================== - - private suspend fun executeTaskWithReasoning(task: Task): Outcome { - if (task is Task.Blank) { - return Outcome.blank - } - - val plan = reasoning.generatePlan(task, emptyList()) - return reasoning.executePlan(plan) { step, _ -> - StepResult.success( - description = "Execute validation: ${step.id}", - details = "Validation step completed", - ) - }.outcome - } - - // ======================================================================== - // Knowledge Extraction - // ======================================================================== - - private fun extractQualityKnowledge( - outcome: Outcome, - task: Task, - plan: Plan, - ): Knowledge.FromOutcome { - val taskDescription = when (task) { - is Task.CodeChange -> task.description - else -> "Generic validation task ${task.id}" - } - - val approachDescription = buildString { - append("Performed ${plan.tasks.size} validation checks for '$taskDescription'. ") - - // Extract check types from plan tasks - val checkTypes = plan.tasks.mapNotNull { t -> - when (t) { - is Task.CodeChange -> { - val desc = t.description.lowercase() - when { - desc.contains("syntax") -> "syntax" - desc.contains("style") -> "style" - desc.contains("logic") -> "logic" - desc.contains("security") -> "security" - desc.contains("performance") -> "performance" - desc.contains("testing") -> "testing" - else -> null - } - } - else -> null - } - }.distinct() - - if (checkTypes.isNotEmpty()) { - append("Checks included: ${checkTypes.joinToString(", ")}. ") - } - append("Complexity: ${plan.estimatedComplexity}") - } - - val learnings = buildString { - when (outcome) { - is Outcome.Success -> { - append("Success: All ${plan.tasks.size} validation checks passed. ") - append("This validation strategy was effective for this type of task. ") - append("Recommend similar validation approach for future tasks.") - } - is Outcome.Failure -> { - append("Failure: Validation checks detected issues. ") - append("The validation approach successfully caught problems. ") - append("Recommend continuing to prioritize these check types.") - } - else -> { - append("Partial validation completion. ") - append("Some checks passed, others may need refinement. ") - append("Consider adjusting validation granularity.") - } - } - } - - return Knowledge.FromOutcome( - outcomeId = outcome.id, - approach = approachDescription, - learnings = learnings, - timestamp = Clock.System.now(), - ) - } - - companion object Companion { - val SYSTEM_PROMPT = QualityPrompts.SYSTEM_PROMPT - } -} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/SparkAgentFactory.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/SparkAgentFactory.kt index 346ce756..6bc94771 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/SparkAgentFactory.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/SparkAgentFactory.kt @@ -2,6 +2,7 @@ package link.socket.ampere.agents.definition import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import link.socket.ampere.agents.definition.code.CodeState import link.socket.ampere.agents.domain.cognition.CognitiveAffinity import link.socket.ampere.agents.domain.cognition.Spark import link.socket.ampere.agents.domain.cognition.sparks.LanguageSpark @@ -54,7 +55,7 @@ class SparkAgentFactory( projectSpark: ProjectSpark, language: LanguageSpark = LanguageSpark.Kotlin, id: AgentId = generateUUID("CodeAgent-${projectSpark.projectId}"), - ): SparkBasedAgent { + ): SparkBasedAgent { val agent = createAgent(id, CognitiveAffinity.ANALYTICAL) agent.applySpark(projectSpark) agent.applySpark(RoleSpark.Code) @@ -78,7 +79,7 @@ class SparkAgentFactory( fun createResearchAgent( projectSpark: ProjectSpark, id: AgentId = generateUUID("ResearchAgent-${projectSpark.projectId}"), - ): SparkBasedAgent { + ): SparkBasedAgent { val agent = createAgent(id, CognitiveAffinity.EXPLORATORY) agent.applySpark(projectSpark) agent.applySpark(RoleSpark.Research) @@ -101,7 +102,7 @@ class SparkAgentFactory( fun createPlanningAgent( projectSpark: ProjectSpark, id: AgentId = generateUUID("PlanningAgent-${projectSpark.projectId}"), - ): SparkBasedAgent { + ): SparkBasedAgent { val agent = createAgent(id, CognitiveAffinity.INTEGRATIVE) agent.applySpark(projectSpark) agent.applySpark(RoleSpark.Planning) @@ -124,7 +125,7 @@ class SparkAgentFactory( fun createOpsAgent( projectSpark: ProjectSpark, id: AgentId = generateUUID("OpsAgent-${projectSpark.projectId}"), - ): SparkBasedAgent { + ): SparkBasedAgent { val agent = createAgent(id, CognitiveAffinity.OPERATIONAL) agent.applySpark(projectSpark) agent.applySpark(RoleSpark.Operations) @@ -143,13 +144,14 @@ class SparkAgentFactory( fun createAgent( id: AgentId, affinity: CognitiveAffinity, - ): SparkBasedAgent { + ): SparkBasedAgent { val eventApi = eventApiFactory?.invoke(id) val memoryService = memoryServiceFactory?.invoke(id) return SparkBasedAgent( agentId = id, cognitiveAffinity = affinity, + initialState = CodeState.blank, _eventApi = eventApi, _memoryService = memoryService, _aiConfiguration = defaultAiConfiguration, @@ -211,8 +213,8 @@ class SparkAgentFactory( /** * Helper extension to apply a spark with simpler syntax. */ -private fun SparkBasedAgent.applySpark(spark: Spark) { - this.spark(spark) +private fun SparkBasedAgent.applySpark(spark: Spark) { + this.spark>(spark) } /** @@ -223,7 +225,7 @@ private fun SparkBasedAgent.applySpark(spark: Spark) { * agent.withSparks(projectSpark, roleSpark, languageSpark) * ``` */ -fun SparkBasedAgent.withSparks(vararg sparks: Spark): SparkBasedAgent { - sparks.forEach { this.spark(it) } +fun SparkBasedAgent.withSparks(vararg sparks: Spark): SparkBasedAgent { + sparks.forEach { this.spark>(it) } return this } diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/SparkBasedAgent.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/SparkBasedAgent.kt index 56085138..0d47417d 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/SparkBasedAgent.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/SparkBasedAgent.kt @@ -7,23 +7,30 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import link.socket.ampere.agents.config.AgentConfiguration import link.socket.ampere.agents.definition.code.CodeState +import link.socket.ampere.agents.definition.product.ProductState +import link.socket.ampere.agents.definition.project.ProjectState +import link.socket.ampere.agents.definition.qa.QualityState import link.socket.ampere.agents.domain.cognition.CognitiveAffinity import link.socket.ampere.agents.domain.cognition.sparks.PhaseSparkLibrary import link.socket.ampere.agents.domain.cognition.sparks.PhaseSparkManager +import link.socket.ampere.agents.domain.cognition.sparks.RoleSpark import link.socket.ampere.agents.domain.knowledge.Knowledge import link.socket.ampere.agents.domain.memory.AgentMemoryService import link.socket.ampere.agents.domain.outcome.ExecutionOutcome import link.socket.ampere.agents.domain.outcome.Outcome import link.socket.ampere.agents.domain.reasoning.AgentReasoning import link.socket.ampere.agents.domain.reasoning.Idea -import link.socket.ampere.agents.domain.reasoning.KnowledgeExtractor import link.socket.ampere.agents.domain.reasoning.Perception import link.socket.ampere.agents.domain.reasoning.Plan import link.socket.ampere.agents.domain.reasoning.StepResult +import link.socket.ampere.agents.domain.state.AgentState import link.socket.ampere.agents.domain.task.Task import link.socket.ampere.agents.events.api.AgentEventApi +import link.socket.ampere.agents.events.utils.generateUUID +import link.socket.ampere.agents.execution.request.ExecutionContext import link.socket.ampere.agents.execution.request.ExecutionRequest import link.socket.ampere.agents.execution.tools.Tool +import link.socket.ampere.agents.execution.tools.planning.ToolPlanSteps import link.socket.ampere.domain.agent.bundled.AgentDefinition import link.socket.ampere.domain.ai.configuration.AIConfiguration import link.socket.ampere.domain.ai.configuration.AIConfigurationFactory @@ -42,19 +49,24 @@ import link.socket.ampere.util.runBlockingCompat * The system prompt is dynamically built from the SparkStack before each LLM * interaction, and tool/file access is computed from Spark constraints. * - * Note: This uses CodeState as a simple state implementation. Custom state types - * can be supported by subclassing. + * Parameterized over [S] so role-specific factories (e.g. + * `SparkBasedAgent`, `SparkBasedAgent`) can carry + * domain-specific state without subclassing for behavior. * * @param agentId Unique identifier for this agent * @param cognitiveAffinity The cognitive affinity that shapes how this agent thinks - * @param eventApi Optional event API for observability - * @param agentMemoryService Optional memory service for knowledge persistence - * @param aiConfiguration Optional AI configuration (uses default if not provided) + * @param initialState The starting state for this agent + * @param _eventApi Optional event API for observability + * @param _memoryService Optional memory service for knowledge persistence + * @param _aiConfiguration Optional AI configuration (uses default if not provided) */ @Serializable -open class SparkBasedAgent( +open class SparkBasedAgent( private val agentId: AgentId, private val cognitiveAffinity: CognitiveAffinity, + override val initialState: S, + @Transient + private val _additionalTools: Set> = emptySet(), @Transient private val _eventApi: AgentEventApi? = null, @Transient @@ -65,7 +77,9 @@ open class SparkBasedAgent( private val _llmProvider: LlmProvider? = null, @Transient private val _observabilityScope: CoroutineScope = CoroutineScope(Dispatchers.Default), -) : ObservableAgent(_eventApi, _observabilityScope) { + @Transient + private val _reasoningOverride: AgentReasoning? = null, +) : ObservableAgent(_eventApi, _observabilityScope) { @Transient private var _phaseSparkLibrary: PhaseSparkLibrary? = null @@ -80,7 +94,7 @@ open class SparkBasedAgent( _phaseSparkLibrary = library } - override fun createPhaseSparkManager(): PhaseSparkManager = + override fun createPhaseSparkManager(): PhaseSparkManager = PhaseSparkManager.createWithLibrary( agent = this, phaseConfig = agentConfiguration.cognitiveConfig.phaseSparks, @@ -91,11 +105,19 @@ open class SparkBasedAgent( override val affinity: CognitiveAffinity = cognitiveAffinity - override val initialState: CodeState = CodeState.blank - @Transient override val memoryService: AgentMemoryService? = _memoryService + /** + * Every Spark-based agent ships with [ToolPlanSteps] by default so that + * the JSON shape of a structured plan lives with the tool that produces + * it rather than being baked into any per-agent profile. Factories layer + * additional domain tools on top via the [_additionalTools] constructor + * parameter. + */ + @Transient + override val requiredTools: Set> = setOf(ToolPlanSteps()) + _additionalTools + private val effectiveAiConfiguration: AIConfiguration get() = _aiConfiguration ?: AIConfigurationFactory.getDefaultConfiguration() @@ -120,7 +142,7 @@ open class SparkBasedAgent( // ======================================================================== private val reasoning: AgentReasoning by lazy { - AgentReasoning.create( + _reasoningOverride ?: AgentReasoning.create( config = agentConfiguration, executorId = id, eventApi = _eventApi, @@ -128,21 +150,6 @@ open class SparkBasedAgent( ) { agentRole = "Spark-Based Agent (${affinity.name})" availableTools = requiredTools - - knowledge { - extractor = { outcome, task, plan -> - KnowledgeExtractor.extract(outcome, task, plan) { - approach { - prefix("Spark Task [${this@SparkBasedAgent.cognitiveState}]") - taskType(task) - planSize(plan) - } - learnings { - fromOutcome(outcome) - } - } - } - } } } @@ -150,8 +157,7 @@ open class SparkBasedAgent( // Neural Agent Implementation // ======================================================================== - @Suppress("UNCHECKED_CAST") - override val runLLMToEvaluatePerception: (Perception) -> Idea = { perception -> + override val runLLMToEvaluatePerception: (Perception) -> Idea = { perception -> runBlockingCompat(ioDispatcher) { withTimeout(60000) { reasoning.evaluatePerception(perception) @@ -172,15 +178,105 @@ open class SparkBasedAgent( withTimeout(60000) { val plan = reasoning.generatePlan(task, emptyList()) reasoning.executePlan(plan) { step, _ -> - StepResult.success( - description = "Executed: ${step.id}", - details = "Completed step", - ) + executePlanStep(step, parentTask = task) }.outcome } } } + /** + * Routes a plan step to its nominated tool. Strict tool-id dispatch with no + * keyword fallback — if [Task.CodeChange.toolId] is missing or doesn't + * match a tool in [requiredTools], the step fails fast with a clear error. + * + * Steps with `toolId == null` are treated as pure reasoning placeholders + * and succeed without invoking anything (the LLM was asked to mark + * tool-less steps with `toolToUse = null` in the plan_steps schema). + */ + private suspend fun executePlanStep(step: Task, parentTask: Task): StepResult { + if (step is Task.Blank) { + return StepResult.success( + description = "blank step", + details = "no-op", + ) + } + if (step !is Task.CodeChange) { + return StepResult.failure( + description = step.id, + error = "Plan step ${step.id} is of unsupported type " + + "${step::class.simpleName}; spark-based execution only " + + "handles Task.CodeChange steps emitted by plan_steps.", + isCritical = true, + ) + } + + val toolId = step.toolId + if (toolId == null) { + return StepResult.success( + description = step.description, + details = "reasoning step (no toolToUse)", + ) + } + + val tool = requiredTools.firstOrNull { it.id == toolId } + ?: return StepResult.failure( + description = step.description, + error = "Plan step ${step.id} nominated toolToUse=\"$toolId\", " + + "which is not in the agent's required tools " + + "(${requiredTools.joinToString { it.id }}). The executor " + + "routes strictly by tool id — no keyword fallback.", + isCritical = true, + ) + + val request = buildPlanStepRequest(step, parentTask) + return when (val outcome = reasoning.executeTool(tool, request)) { + is ExecutionOutcome.Success -> StepResult.success( + description = step.description, + details = "tool=$toolId outcome=${outcome::class.simpleName}", + ) + is ExecutionOutcome.Failure -> StepResult.failure( + description = step.description, + error = "tool=$toolId failed: ${outcome::class.simpleName}", + isCritical = true, + ) + else -> StepResult.success( + description = step.description, + details = "tool=$toolId outcome=${outcome::class.simpleName}", + ) + } + } + + /** + * Builds the initial request handed to the tool's parameter strategy. The + * strategy enriches this with a tool-specific context (e.g. promotes the + * generic [ExecutionContext.NoChanges] wrapper to + * [ExecutionContext.GitOperation] when invoking a git tool); when no + * strategy is registered the tool must be able to act on the raw request. + */ + private fun buildPlanStepRequest(step: Task.CodeChange, parentTask: Task): ExecutionRequest<*> { + val ticket = link.socket.ampere.agents.events.tickets.Ticket( + id = "spark-task-${parentTask.id}", + title = parentTask.id, + description = step.description, + type = link.socket.ampere.agents.events.tickets.TicketType.TASK, + priority = link.socket.ampere.agents.events.tickets.TicketPriority.LOW, + status = link.socket.ampere.agents.domain.status.TicketStatus.InProgress, + assignedAgentId = id, + createdByAgentId = id, + createdAt = kotlinx.datetime.Clock.System.now(), + updatedAt = kotlinx.datetime.Clock.System.now(), + ) + return ExecutionRequest( + context = ExecutionContext.NoChanges( + executorId = id, + ticket = ticket, + task = step, + instructions = step.description, + ), + constraints = link.socket.ampere.agents.execution.request.ExecutionConstraints(), + ) + } + override val runLLMToExecuteTool: (Tool<*>, ExecutionRequest<*>) -> ExecutionOutcome = { tool, request -> runBlockingCompat(ioDispatcher) { withTimeout(60000) { @@ -208,4 +304,188 @@ open class SparkBasedAgent( reasoning.callLLM(prompt) } } + + companion object { + + /** + * Resource id of the bundled declarative spark that supplies the + * Code agent's per-phase guidance. Activated during phase entry + * when a `PhaseSparkLibrary` containing it has been wired into + * the agent. + */ + const val CODE_AGENT_SPARK_ID: String = "code-agent" + + /** + * Builds a Code-focused [SparkBasedAgent]: `ANALYTICAL` affinity, + * the [RoleSpark.Code] role spark stacked at construction time, + * and the `plan_steps` tool already in its toolset. + * + * The factory is the supported entry point for a code agent in + * the spark world. It mirrors the constructor shape of the + * legacy `CodeAgent` so call sites can swap implementations + * without restructuring their dependency graph. + * + * The declarative `code-agent.spark.md` guidance is **not** + * applied here. That is the responsibility of the surrounding + * `AgentFactory` (or test harness), which wires a loaded + * `PhaseSparkLibrary` via the agent's internal setter before the + * first cognitive phase entry. Keeping the library hand-off off + * the factory keeps the public surface free of the spark + * library's internal interface while still giving the factory a + * one-line construction story. + * + * @param tools additional tools layered on top of the default + * `plan_steps` tool (typically a code-writing tool plus the + * git tool set). Tool-owned parameter strategies, if any, + * travel with the tools themselves. + */ + fun Code( + agentId: AgentId = generateUUID("SparkBasedAgent-Code"), + aiConfiguration: AIConfiguration? = null, + eventApi: AgentEventApi? = null, + memoryService: AgentMemoryService? = null, + llmProvider: LlmProvider? = null, + observabilityScope: CoroutineScope = CoroutineScope(Dispatchers.Default), + tools: Set> = emptySet(), + reasoningOverride: AgentReasoning? = null, + ): SparkBasedAgent { + val agent = SparkBasedAgent( + agentId = agentId, + cognitiveAffinity = CognitiveAffinity.ANALYTICAL, + initialState = CodeState.blank, + _additionalTools = tools, + _eventApi = eventApi, + _memoryService = memoryService, + _aiConfiguration = aiConfiguration, + _llmProvider = llmProvider, + _observabilityScope = observabilityScope, + _reasoningOverride = reasoningOverride, + ) + agent.spark>(RoleSpark.Code) + return agent + } + + /** + * Resource id of the bundled declarative spark that supplies the + * Product agent's per-phase guidance. + */ + const val PRODUCT_AGENT_SPARK_ID: String = "product-agent" + + /** + * Builds a Product-focused [SparkBasedAgent]: `INTEGRATIVE` + * affinity, [RoleSpark.Planning] stacked at construction time, + * and the `plan_steps` tool already in its toolset. + * + * Mirrors the legacy `ProductAgent` shape. Declarative + * `product-agent.spark.md` guidance is wired separately by the + * surrounding `AgentFactory`. + */ + fun Product( + agentId: AgentId = generateUUID("SparkBasedAgent-Product"), + aiConfiguration: AIConfiguration? = null, + eventApi: AgentEventApi? = null, + memoryService: AgentMemoryService? = null, + llmProvider: LlmProvider? = null, + observabilityScope: CoroutineScope = CoroutineScope(Dispatchers.Default), + tools: Set> = emptySet(), + reasoningOverride: AgentReasoning? = null, + ): SparkBasedAgent { + val agent = SparkBasedAgent( + agentId = agentId, + cognitiveAffinity = CognitiveAffinity.INTEGRATIVE, + initialState = ProductState.blank, + _additionalTools = tools, + _eventApi = eventApi, + _memoryService = memoryService, + _aiConfiguration = aiConfiguration, + _llmProvider = llmProvider, + _observabilityScope = observabilityScope, + _reasoningOverride = reasoningOverride, + ) + agent.spark>(RoleSpark.Planning) + return agent + } + + /** + * Resource id of the bundled declarative spark that supplies the + * Project agent's per-phase guidance. + */ + const val PROJECT_AGENT_SPARK_ID: String = "project-agent" + + /** + * Builds a Project-focused [SparkBasedAgent]: `INTEGRATIVE` + * affinity, [RoleSpark.Planning] stacked at construction time, + * and the `plan_steps` tool already in its toolset. + * + * Mirrors the legacy `ProjectAgent` shape. Typical tool stack + * includes the issue-creation tool and the human-escalation + * tool, each carrying its own `ProjectParams.*` parameter + * strategy. + */ + fun Project( + agentId: AgentId = generateUUID("SparkBasedAgent-Project"), + aiConfiguration: AIConfiguration? = null, + eventApi: AgentEventApi? = null, + memoryService: AgentMemoryService? = null, + llmProvider: LlmProvider? = null, + observabilityScope: CoroutineScope = CoroutineScope(Dispatchers.Default), + tools: Set> = emptySet(), + reasoningOverride: AgentReasoning? = null, + ): SparkBasedAgent { + val agent = SparkBasedAgent( + agentId = agentId, + cognitiveAffinity = CognitiveAffinity.INTEGRATIVE, + initialState = ProjectState.blank, + _additionalTools = tools, + _eventApi = eventApi, + _memoryService = memoryService, + _aiConfiguration = aiConfiguration, + _llmProvider = llmProvider, + _observabilityScope = observabilityScope, + _reasoningOverride = reasoningOverride, + ) + agent.spark>(RoleSpark.Planning) + return agent + } + + /** + * Resource id of the bundled declarative spark that supplies the + * Quality agent's per-phase guidance. + */ + const val QUALITY_AGENT_SPARK_ID: String = "quality-agent" + + /** + * Builds a Quality-focused [SparkBasedAgent]: `ANALYTICAL` + * affinity, [RoleSpark.Code] stacked at construction time + * (validation work reads & runs code), and the `plan_steps` + * tool already in its toolset. + * + * Mirrors the legacy `QualityAgent` shape. + */ + fun Quality( + agentId: AgentId = generateUUID("SparkBasedAgent-Quality"), + aiConfiguration: AIConfiguration? = null, + eventApi: AgentEventApi? = null, + memoryService: AgentMemoryService? = null, + llmProvider: LlmProvider? = null, + observabilityScope: CoroutineScope = CoroutineScope(Dispatchers.Default), + tools: Set> = emptySet(), + reasoningOverride: AgentReasoning? = null, + ): SparkBasedAgent { + val agent = SparkBasedAgent( + agentId = agentId, + cognitiveAffinity = CognitiveAffinity.ANALYTICAL, + initialState = QualityState.blank, + _additionalTools = tools, + _eventApi = eventApi, + _memoryService = memoryService, + _aiConfiguration = aiConfiguration, + _llmProvider = llmProvider, + _observabilityScope = observabilityScope, + _reasoningOverride = reasoningOverride, + ) + agent.spark>(RoleSpark.Code) + return agent + } + } } diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/code/CodeAgentGitHelpers.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/code/CodeAgentGitHelpers.kt index c6f29edd..20e0ec7d 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/code/CodeAgentGitHelpers.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/code/CodeAgentGitHelpers.kt @@ -3,7 +3,7 @@ package link.socket.ampere.agents.definition.code import link.socket.ampere.integrations.issues.ExistingIssue /** - * Git workflow helper functions for CodeAgent. + * Git workflow helper functions for the spark-based code agent's issue workflow. * * These functions generate branch names, commit messages, and PR content * following conventions and best practices for the issue-to-branch workflow. diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/code/IssueWorkflowStatus.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/code/IssueWorkflowStatus.kt index fe6071d6..0803e06f 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/code/IssueWorkflowStatus.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/code/IssueWorkflowStatus.kt @@ -3,7 +3,7 @@ package link.socket.ampere.agents.definition.code import kotlinx.serialization.Serializable /** - * Issue workflow status tracking for CodeAgent. + * Issue workflow status tracking for the spark-based code agent's issue workflow. * * Represents the lifecycle of an issue as it moves through the development workflow: * 1. CLAIMED - Agent has claimed the issue diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DefaultPhaseSparkLibrary.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DefaultPhaseSparkLibrary.kt index 8003c952..54dec2aa 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DefaultPhaseSparkLibrary.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DefaultPhaseSparkLibrary.kt @@ -9,6 +9,10 @@ private val DEFAULT_SPARKS: List = listOf( "files/sparks/cooking-domain.spark.md", "files/sparks/recipe-arc-task.spark.md", "files/sparks/minimal-edge.spark.md", + "files/sparks/code-agent.spark.md", + "files/sparks/product-agent.spark.md", + "files/sparks/project-agent.spark.md", + "files/sparks/quality-agent.spark.md", ) /** diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/LanguageSpark.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/LanguageSpark.kt index 01103e79..55f4c8e8 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/LanguageSpark.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/LanguageSpark.kt @@ -2,6 +2,7 @@ package link.socket.ampere.agents.domain.cognition.sparks import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import link.socket.ampere.agents.domain.cognition.FileAccessScope import link.socket.ampere.agents.domain.cognition.Spark import link.socket.ampere.agents.domain.cognition.ToolId @@ -70,8 +71,53 @@ You are working with **Kotlin** code. Follow these idioms and best practices: - Use meaningful names that express intent - Keep functions small and focused - Document public APIs with KDoc + +### File and Package Conventions + +- Source roots: `src/commonMain/kotlin/`, `src/jvmMain/kotlin/`, + `src/main/kotlin/`. The `package` declaration mirrors the path under the + source root. + - `src/commonMain/kotlin/link/socket/ampere/User.kt` → `package link.socket.ampere` + - `src/main/kotlin/com/example/Foo.kt` → `package com.example` +- Every generated `.kt` file starts with its `package` declaration followed + by its imports. +- Prefer one top-level declaration per file when the file is named after + the declaration; multi-declaration files are fine when the declarations + are tightly related (e.g. a sealed class and its `data` subclasses). + +### When Generating Code + +- Generate **complete, compilable** code — no `TODO`s, no placeholders, no + partial implementations. +- Include every necessary import; rely on the package convention above for + the `package` line. +- Add KDoc on public APIs. """.trimIndent() + @Transient + override val phaseContributions: Map = mapOf( + CognitivePhase.PLAN to """ +### Kotlin planning notes + +- When a step will produce new files, name them with `.kt` (or `.kts` for + Gradle scripts) and place them under the appropriate source root + (`commonMain`, `jvmMain`, `androidMain`, etc.) so the `package` line + follows from the path. +- Group related declarations into the same step when they belong in the + same file (a sealed class plus its subclasses, an interface plus its + default implementations). + """.trimIndent(), + CognitivePhase.EXECUTE to """ +### Kotlin execution notes + +- Compilation-equivalent failure modes you should surface as critical: + missing `package` declaration, unresolved imports, signature mismatch + against `expect`/`actual` declarations. +- Treat warnings about unsafe casts (`as`), `!!`, or platform-typed + values as something to fix in the same step rather than defer. + """.trimIndent(), + ) + override val allowedTools: Set? = null // Inherits from role override val fileAccessScope: FileAccessScope = FileAccessScope( diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/outcome/ExecutionOutcome.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/outcome/ExecutionOutcome.kt index 828c97af..4bc7b461 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/outcome/ExecutionOutcome.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/outcome/ExecutionOutcome.kt @@ -3,6 +3,7 @@ package link.socket.ampere.agents.domain.outcome import kotlinx.datetime.Instant import kotlinx.serialization.Serializable import link.socket.ampere.agents.domain.error.ExecutionError +import link.socket.ampere.agents.domain.reasoning.Plan import link.socket.ampere.agents.domain.task.TaskId import link.socket.ampere.agents.events.tickets.TicketId import link.socket.ampere.agents.events.utils.generateUUID @@ -223,6 +224,42 @@ sealed interface ExecutionOutcome : Outcome { } } + /** + * Outcomes from the `plan_steps` tool. Success carries the structured [Plan] + * the agent's planning phase can hand off to its executor. + */ + @Serializable + sealed interface Planning : ExecutionOutcome { + + @Serializable + data class Success( + override val executorId: ExecutorId, + override val ticketId: TicketId, + override val taskId: TaskId, + override val executionStartTimestamp: Instant, + override val executionEndTimestamp: Instant, + val plan: Plan, + ) : Planning, ExecutionOutcome.Success { + + override val id: OutcomeId = + generateUUID(executorId) + } + + @Serializable + data class Failure( + override val executorId: ExecutorId, + override val ticketId: TicketId, + override val taskId: TaskId, + override val executionStartTimestamp: Instant, + override val executionEndTimestamp: Instant, + val error: ExecutionError, + ) : Planning, ExecutionOutcome.Failure { + + override val id: OutcomeId = + generateUUID(executorId) + } + } + companion object { val blank: ExecutionOutcome = Blank } diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/outcome/OutcomeMemoryRepositoryImpl.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/outcome/OutcomeMemoryRepositoryImpl.kt index bb5f1e18..c156e492 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/outcome/OutcomeMemoryRepositoryImpl.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/outcome/OutcomeMemoryRepositoryImpl.kt @@ -75,6 +75,12 @@ class OutcomeMemoryRepositoryImpl( is ExecutionOutcome.GitOperation.Failure -> { 0 to outcome.error.message } + is ExecutionOutcome.Planning.Success -> { + outcome.plan.tasks.size to null + } + is ExecutionOutcome.Planning.Failure -> { + 0 to outcome.error.message + } is ExecutionOutcome.Blank -> { 0 to null } diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/reasoning/AgentReasoning.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/reasoning/AgentReasoning.kt index 9acb009c..aa8ea933 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/reasoning/AgentReasoning.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/reasoning/AgentReasoning.kt @@ -24,37 +24,26 @@ import link.socket.ampere.plugin.permission.UserGrants * Unified reasoning facade that composes all cognitive services. * * This is the primary interface agents use to access reasoning capabilities. - * It combines all the individual services (LLM, perception, planning, execution, - * evaluation, knowledge extraction) into a cohesive API. + * It combines all the individual services (LLM, perception, planning, + * execution, evaluation, knowledge extraction) into a cohesive API. * - * Agents configure this facade with their specific customizations: - * - Context builders for perception - * - Custom prompts for planning - * - Task factories for plan generation - * - Parameter strategies for tool execution - * - Knowledge extraction customizations + * Per-phase context builders, planning prompt builders, and custom knowledge + * extractors were deleted by AMPR-163 Task 11; spark-based agents now express + * that guidance through their stacked `.spark.md` per-phase contributions and + * fall through to the generic `KnowledgeExtractor.extractDefault` for + * knowledge extraction. * * Usage: * ```kotlin - * val reasoning = AgentReasoning.create(config) { - * agentRole = "Project Manager" - * availableTools = setOf(toolCreateIssues, toolAskHuman) - * - * perception { - * contextBuilder = { state -> buildPMContext(state) } - * } - * - * planning { - * taskFactory = PMTaskFactory - * customPrompt = { task, ideas, tools, knowledge -> buildPMPrompt(...) } - * } + * val reasoning = AgentReasoning.create(config, executorId, eventApi, activePromptProvider) { + * agentRole = "Spark-Based Agent (ANALYTICAL)" + * availableTools = requiredTools * * execution { * registerStrategy("create_issues", ProjectParams.IssueCreation(...)) * } * } * - * // Use in agent * val idea = reasoning.evaluatePerception(perception) * val plan = reasoning.generatePlan(task, ideas) * val outcome = reasoning.executeTool(tool, request) @@ -111,11 +100,7 @@ class AgentReasoning private constructor( return perceptionEvaluator?.evaluate( perception = perception, - contextBuilder = { state -> - @Suppress("UNCHECKED_CAST") - settings.perceptionContextBuilder?.invoke(state as AgentState) - ?: "State: $state" - }, + contextBuilder = { state -> "State: $state" }, agentRole = settings.agentRole, availableTools = settings.availableTools, ) ?: throw IllegalStateException("No perception evaluator configured") @@ -145,7 +130,7 @@ class AgentReasoning private constructor( availableTools = settings.availableTools, relevantKnowledge = relevantKnowledge, taskFactory = settings.taskFactory, - customPromptBuilder = settings.planningPromptBuilder, + customPromptBuilder = null, ) ?: throw IllegalStateException("No plan generator configured") } @@ -205,7 +190,7 @@ class AgentReasoning private constructor( val result = outcomeEvaluator?.evaluate( outcomes = outcomes, agentRole = settings.agentRole, - contextBuilder = settings.outcomeContextBuilder, + contextBuilder = null, ) ?: throw IllegalStateException("No outcome evaluator configured") // Store learnings in memory if available @@ -219,16 +204,17 @@ class AgentReasoning private constructor( } /** - * Extracts knowledge from a single outcome. + * Extracts knowledge from a single outcome using the generic + * `KnowledgeExtractor.extractDefault`. Per-agent custom extractors were + * removed by AMPR-163 Task 11; agents now express role-specific learning + * guidance through the `## When Learning` section of their `.spark.md`. */ fun extractKnowledge( outcome: Outcome, task: Task, plan: Plan, - ): Knowledge.FromOutcome { - return settings.knowledgeExtractor?.invoke(outcome, task, plan) - ?: KnowledgeExtractor.extractDefault(outcome, task, plan, settings.agentRole) - } + ): Knowledge.FromOutcome = + KnowledgeExtractor.extractDefault(outcome, task, plan, settings.agentRole) // ======================================================================== // Direct LLM Access @@ -318,13 +304,9 @@ class AgentReasoning private constructor( agentRole = "Test Agent", availableTools = emptySet(), executor = null, - perceptionContextBuilder = null, - planningPromptBuilder = null, taskFactory = DefaultTaskFactory, parameterStrategies = emptyMap(), userGrantProvider = { UserGrants() }, - outcomeContextBuilder = null, - knowledgeExtractor = null, ) return AgentReasoning( config = null, @@ -387,59 +369,44 @@ class MockReasoningBuilder { } /** - * Settings for configuring agent reasoning behavior. + * Settings for configuring agent reasoning behaviour. + * + * AMPR-163 Task 11 removed the per-phase customisation fields + * (perceptionContextBuilder, planningPromptBuilder, outcomeContextBuilder, + * knowledgeExtractor): role-specific guidance now lives in stacked + * `.spark.md` per-phase contributions instead of in agent-side Kotlin + * builders. */ data class ReasoningSettings( val executorId: ExecutorId, val agentRole: String, val availableTools: Set>, val executor: Executor?, - val perceptionContextBuilder: ((AgentState) -> String)?, - val planningPromptBuilder: ((Task, List, Set>, List) -> String)?, val taskFactory: TaskFactory, val parameterStrategies: Map, val userGrantProvider: suspend (PluginManifest) -> UserGrants, - val outcomeContextBuilder: ((List) -> String)?, - val knowledgeExtractor: ((Outcome, Task, Plan) -> Knowledge.FromOutcome)?, ) /** - * Builder for ReasoningSettings. + * Builder for [ReasoningSettings]. + * + * The DSL is intentionally narrow after AMPR-163 Task 11. Only + * [execution] survives — it registers parameter strategies and the + * user-grant provider. Per-phase prompt/context customisation has moved + * to the `.spark.md` artifacts the agent stacks at construction time. */ class ReasoningSettingsBuilder(private val executorId: ExecutorId) { var agentRole: String = "Agent" var availableTools: Set> = emptySet() var executor: Executor? = null + var taskFactory: TaskFactory = DefaultTaskFactory - private var perceptionContextBuilder: ((AgentState) -> String)? = null - private var planningPromptBuilder: ((Task, List, Set>, List) -> String)? = null - private var taskFactory: TaskFactory = DefaultTaskFactory private val parameterStrategies = mutableMapOf() private var userGrantProvider: suspend (PluginManifest) -> UserGrants = { UserGrants() } - private var outcomeContextBuilder: ((List) -> String)? = null - private var knowledgeExtractor: ((Outcome, Task, Plan) -> Knowledge.FromOutcome)? = null /** - * Configure perception settings. - */ - fun perception(configure: PerceptionSettingsBuilder.() -> Unit) { - val builder = PerceptionSettingsBuilder() - builder.configure() - perceptionContextBuilder = builder.contextBuilder - } - - /** - * Configure planning settings. - */ - fun planning(configure: PlanningSettingsBuilder.() -> Unit) { - val builder = PlanningSettingsBuilder() - builder.configure() - planningPromptBuilder = builder.customPrompt - builder.taskFactory?.let { taskFactory = it } - } - - /** - * Configure execution settings. + * Configure execution settings (parameter strategies + user-grant + * provider). The only surviving per-phase DSL after AMPR-163 Task 11. */ fun execution(configure: ExecutionSettingsBuilder.() -> Unit) { val builder = ExecutionSettingsBuilder() @@ -448,48 +415,15 @@ class ReasoningSettingsBuilder(private val executorId: ExecutorId) { userGrantProvider = builder.userGrantProvider } - /** - * Configure outcome evaluation settings. - */ - fun evaluation(configure: EvaluationSettingsBuilder.() -> Unit) { - val builder = EvaluationSettingsBuilder() - builder.configure() - outcomeContextBuilder = builder.contextBuilder - } - - /** - * Configure knowledge extraction settings. - */ - fun knowledge(configure: KnowledgeSettingsBuilder.() -> Unit) { - val builder = KnowledgeSettingsBuilder() - builder.configure() - knowledgeExtractor = builder.extractor - } - - fun build(): ReasoningSettings { - return ReasoningSettings( - executorId = executorId, - agentRole = agentRole, - availableTools = availableTools, - executor = executor, - perceptionContextBuilder = perceptionContextBuilder, - planningPromptBuilder = planningPromptBuilder, - taskFactory = taskFactory, - parameterStrategies = parameterStrategies.toMap(), - userGrantProvider = userGrantProvider, - outcomeContextBuilder = outcomeContextBuilder, - knowledgeExtractor = knowledgeExtractor, - ) - } -} - -class PerceptionSettingsBuilder { - var contextBuilder: ((AgentState) -> String)? = null -} - -class PlanningSettingsBuilder { - var customPrompt: ((Task, List, Set>, List) -> String)? = null - var taskFactory: TaskFactory? = null + fun build(): ReasoningSettings = ReasoningSettings( + executorId = executorId, + agentRole = agentRole, + availableTools = availableTools, + executor = executor, + taskFactory = taskFactory, + parameterStrategies = parameterStrategies.toMap(), + userGrantProvider = userGrantProvider, + ) } class ExecutionSettingsBuilder { @@ -504,11 +438,3 @@ class ExecutionSettingsBuilder { userGrantProvider = provider } } - -class EvaluationSettingsBuilder { - var contextBuilder: ((List) -> String)? = null -} - -class KnowledgeSettingsBuilder { - var extractor: ((Outcome, Task, Plan) -> Knowledge.FromOutcome)? = null -} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/reasoning/PlanGenerator.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/reasoning/PlanGenerator.kt index 676b95e5..8b1bd96c 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/reasoning/PlanGenerator.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/reasoning/PlanGenerator.kt @@ -324,6 +324,7 @@ object DefaultTaskFactory : TaskFactory { status = TaskStatus.Pending, description = description, assignedTo = if (originalTask is Task.CodeChange) originalTask.assignedTo else null, + toolId = toolId, ) } } diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/routing/RoutingContext.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/routing/RoutingContext.kt index 3d08d300..77089af2 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/routing/RoutingContext.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/routing/RoutingContext.kt @@ -15,7 +15,7 @@ import link.socket.ampere.domain.ai.model.AIModelFeatures.RelativeSpeed * * @property phase The cognitive phase active during this call, if any. * @property agentId The agent making the call. - * @property agentRole The agent definition name (e.g., "CodeAgent", "ProductAgent"). + * @property agentRole The agent role label (e.g., `"Code Writer"`, `"Product Manager"`). * @property workflowId Optional correlation ID for the broader reasoning unit being executed. * @property preferredReasoning Hint for desired reasoning level. * @property preferredSpeed Hint for desired speed. diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/routing/RoutingRule.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/routing/RoutingRule.kt index abc4d7b4..72a9936b 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/routing/RoutingRule.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/routing/RoutingRule.kt @@ -53,7 +53,7 @@ sealed interface RoutingRule { /** * Routes based on the agent's role name. * - * Example: "CodeAgent" -> coding-specialized model. + * Example: `"Code Writer"` -> coding-specialized model. */ @Serializable data class ByRole( diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/task/Task.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/task/Task.kt index 2130d816..d584dff8 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/task/Task.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/task/Task.kt @@ -24,6 +24,14 @@ sealed interface Task { override val status: TaskStatus, val description: String, val assignedTo: AssignedTo? = null, + /** + * Tool id this step nominates for execution. Populated by the planning + * pipeline from the LLM's `toolToUse` field; `null` denotes a pure + * reasoning step that performs no tool invocation. + * + * The executor routes strictly on this id with no keyword fallback. + */ + val toolId: String? = null, ) : Task companion object { diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/AutonomousWorkLoop.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/AutonomousWorkLoop.kt index c496ab53..2bf8adc6 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/AutonomousWorkLoop.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/AutonomousWorkLoop.kt @@ -13,9 +13,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.datetime.Clock import link.socket.ampere.agents.definition.AgentId -import link.socket.ampere.agents.definition.CodeAgent +import link.socket.ampere.agents.definition.AutonomousAgent import link.socket.ampere.agents.domain.Urgency +import link.socket.ampere.agents.domain.state.AgentState import link.socket.ampere.agents.events.api.AgentEventApi +import link.socket.ampere.agents.execution.issue.CodeIssueWorkflow /** * Configuration for autonomous work loop behavior. @@ -35,93 +37,39 @@ data class WorkLoopConfig( ) /** - * Manages autonomous work loop for CodeAgent. + * Manages the autonomous issue-processing loop for a code agent. * - * This class implements the continuous polling strategy for autonomous issue processing: - * 1. Poll for available issues - * 2. Claim an issue (with optimistic locking) - * 3. Execute the full workflow (plan → code → PR) - * 4. Repeat + * Continuously polls a [CodeIssueWorkflow] for available issues, claims + * them with optimistic locking, and hands the work to the supplied agent + * via the workflow's `workOnIssue` path. + * + * Agent-agnostic — accepts any [AutonomousAgent] (typically a + * `SparkBasedAgent` built by `SparkBasedAgent.Code(...)`). * * Features: * - **Exponential Backoff**: When no work is available, polling slows down exponentially * - **Rate Limiting**: Prevents runaway execution by limiting issues per hour * - **Graceful Shutdown**: Clean cancellation via stop() * - **Error Recovery**: Continues operation even if individual issues fail - * - * ## Usage - * - * ```kotlin - * val loop = AutonomousWorkLoop( - * agent = codeAgent, - * config = WorkLoopConfig(maxIssuesPerHour = 5), - * scope = coroutineScope - * ) - * - * loop.start() // Begin autonomous operation - * // ... - * loop.stop() // Stop gracefully - * ``` - * - * ## Backoff Strategy - * - * When no issues are available, the polling interval increases: - * - 0 consecutive: 30s - * - 1 consecutive: 60s (1 minute) - * - 2 consecutive: 120s (2 minutes) - * - 3+ consecutive: 300s (5 minutes, capped) - * - * This prevents excessive API calls when the issue queue is empty. - * - * ## Rate Limiting - * - * To prevent runaway execution or API abuse, the loop enforces a maximum - * number of issues per hour. Once the limit is reached, the loop backs off - * for the configured backoff interval. - * - * @param agent The CodeAgent instance to execute workflows - * @param config Configuration for loop behavior - * @param scope CoroutineScope for launching the polling loop - * @param eventApiFactory Optional factory for creating event APIs to publish work events */ -class AutonomousWorkLoop( - private val agent: CodeAgent, +class AutonomousWorkLoop( + private val agent: AutonomousAgent, + private val workflow: CodeIssueWorkflow, private val config: WorkLoopConfig = WorkLoopConfig(), private val scope: CoroutineScope, private val eventApiFactory: ((AgentId) -> AgentEventApi)? = null, ) { - /** - * Event API for publishing work events to the event bus. - * Lazily created using the agent's ID. - */ private val eventApi: AgentEventApi? by lazy { eventApiFactory?.invoke(agent.id) } private val _isRunning = MutableStateFlow(false) - /** - * Observable state indicating whether the work loop is currently running. - */ val isRunning: StateFlow = _isRunning.asStateFlow() private var job: Job? = null private var issuesProcessedThisHour = 0 private var hourStartTime = Clock.System.now().toEpochMilliseconds() - /** - * Start the autonomous work loop. - * - * Begins continuous polling for available issues. The loop will: - * 1. Check rate limits - * 2. Query for available issues - * 3. Attempt to claim the first available issue - * 4. Execute the full workflow on claimed issues - * 5. Apply backoff when no work is available - * - * If the loop is already running, this is a no-op. - * - * The loop runs until stop() is called or an unrecoverable error occurs. - */ fun start() { if (_isRunning.value) return @@ -131,35 +79,26 @@ class AutonomousWorkLoop( while (_isRunning.value) { try { - // Rate limit check if (shouldThrottleForRateLimit()) { delay(config.backoffInterval) continue } - // Discover available issues - val issues = agent.queryAvailableIssues() - + val issues = workflow.queryAvailableIssues() if (issues.isEmpty()) { consecutiveNoWork++ - val backoff = calculateBackoff(consecutiveNoWork) - delay(backoff) + delay(calculateBackoff(consecutiveNoWork)) continue } - consecutiveNoWork = 0 - // Try to claim and work on first issue val issue = issues.first() - val claimed = agent.claimIssue(issue.number) - + val claimed = workflow.claimIssue(issue.number) if (claimed.isFailure) { - // Another agent claimed it, continue to next delay(config.pollingInterval) continue } - // Publish task created event so dashboard sees the work eventApi?.publishTaskCreated( taskId = "issue-${issue.number}", urgency = Urgency.MEDIUM, @@ -167,11 +106,9 @@ class AutonomousWorkLoop( assignedTo = agent.id, ) - // Work on the issue - val result = agent.workOnIssue(issue) + val result = workflow.workOnIssue(issue, agent) issuesProcessedThisHour++ - // Publish completion event if (result.isSuccess) { eventApi?.publishCodeSubmitted( urgency = Urgency.LOW, @@ -184,7 +121,6 @@ class AutonomousWorkLoop( delay(config.pollingInterval) } catch (e: Exception) { - // Log error and continue println("Error in autonomous work loop: ${e.message}") delay(config.backoffInterval) } @@ -194,33 +130,12 @@ class AutonomousWorkLoop( } } - /** - * Stop the autonomous work loop. - * - * Gracefully cancels the polling loop. Any in-progress issue will complete, - * but no new issues will be claimed. - * - * This method is idempotent - calling it multiple times is safe. - */ fun stop() { _isRunning.value = false job?.cancel() } - /** - * Calculate exponential backoff delay based on consecutive failures to find work. - * - * Implements exponential backoff with a cap: - * - 0 consecutive: 30 seconds - * - 1 consecutive: 60 seconds (1 minute) - * - 2 consecutive: 120 seconds (2 minutes) - * - 3+ consecutive: 300 seconds (5 minutes, capped) - * - * @param consecutiveNoWork Number of consecutive polls that found no work - * @return Delay duration before next poll - */ private fun calculateBackoff(consecutiveNoWork: Int): Duration { - // Exponential backoff: 30s, 1m, 2m, 5m (capped) val seconds = minOf( 30 * 2.0.pow(consecutiveNoWork.toDouble()).toLong(), 300, @@ -228,17 +143,6 @@ class AutonomousWorkLoop( return seconds.seconds } - /** - * Check if we should throttle due to rate limiting. - * - * Rate limiting is enforced per-hour. Once the configured maximum - * issues per hour is reached, this returns true until the next hour begins. - * - * The hour window resets when: - * - More than 1 hour has elapsed since hourStartTime - * - * @return true if rate limit is exceeded, false otherwise - */ private fun shouldThrottleForRateLimit(): Boolean { val now = Clock.System.now().toEpochMilliseconds() val hourElapsed = (now - hourStartTime) > 3600_000 diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/issue/CodeIssueWorkflow.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/issue/CodeIssueWorkflow.kt new file mode 100644 index 00000000..f99d5e81 --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/issue/CodeIssueWorkflow.kt @@ -0,0 +1,236 @@ +package link.socket.ampere.agents.execution.issue + +import kotlinx.coroutines.delay +import link.socket.ampere.agents.definition.AgentId +import link.socket.ampere.agents.definition.AutonomousAgent +import link.socket.ampere.agents.definition.code.IssueWorkflowStatus +import link.socket.ampere.agents.domain.outcome.Outcome +import link.socket.ampere.agents.domain.state.AgentState +import link.socket.ampere.agents.domain.status.TaskStatus +import link.socket.ampere.agents.domain.task.Task +import link.socket.ampere.integrations.issues.ExistingIssue +import link.socket.ampere.integrations.issues.IssueQuery +import link.socket.ampere.integrations.issues.IssueState +import link.socket.ampere.integrations.issues.IssueTrackerProvider +import link.socket.ampere.integrations.issues.IssueUpdate + +/** + * Issue → task → PR workflow extracted from the legacy `CodeAgent`. + * + * Encapsulates the integration layer between an [IssueTrackerProvider] and an + * autonomous agent: discovers issues, claims them with optimistic locking, + * hands the work to the agent as a [Task.CodeChange], and reflects progress + * back as label updates on the issue. + * + * Agent-agnostic — works with any [AutonomousAgent]<*> the surrounding loop + * dispatches the task to (typically a `SparkBasedAgent` produced + * by `SparkBasedAgent.Code(...)`). + * + * @param issueTrackerProvider Source of issues (typically a GitHub provider). + * @param repository Repository identifier the provider scopes its queries to. + * @param agentId Identifier of the agent doing the work — used in claim + * comments and (eventually) in the claim-by-assignee lookup. + */ +class CodeIssueWorkflow( + private val issueTrackerProvider: IssueTrackerProvider, + private val repository: String, + private val agentId: AgentId, +) { + + /** Open issues matching this workflow's intake criteria. */ + suspend fun queryAvailableIssues(): List = + issueTrackerProvider.queryIssues( + repository = repository, + query = IssueQuery( + state = IssueState.Open, + assignee = null, + labels = listOf("code", "task"), + limit = 10, + ), + ).getOrElse { emptyList() } + + /** Open issues already assigned to this workflow's agent. */ + suspend fun queryAssignedIssues(): List = + issueTrackerProvider.queryIssues( + repository = repository, + query = IssueQuery( + state = IssueState.Open, + assignee = "CodeWriterAgent", + labels = emptyList(), + limit = 20, + ), + ).getOrElse { emptyList() } + + /** + * Attempt to claim an unassigned issue using optimistic locking. + * + * Reads the current state → checks for prior workflow labels → writes the + * CLAIMED transition → re-reads to detect lost races against another + * agent that wrote concurrently. + */ + suspend fun claimIssue(issueNumber: Int): Result { + try { + val currentIssue = issueTrackerProvider.queryIssues( + repository = repository, + query = IssueQuery(state = IssueState.Open, limit = 100), + ).getOrNull()?.find { it.number == issueNumber } + ?: return Result.failure( + IllegalArgumentException("Issue #$issueNumber not found"), + ) + + val currentStatus = IssueWorkflowStatus.fromLabels(currentIssue.labels) + if (currentStatus != null) { + return Result.failure( + IllegalStateException("Issue already in ${currentStatus.name} status"), + ) + } + + val updateResult = updateIssueStatus( + issueNumber = issueNumber, + status = IssueWorkflowStatus.CLAIMED, + comment = "$agentId claiming this issue", + ) + if (updateResult.isFailure) { + return Result.failure( + updateResult.exceptionOrNull() ?: Exception("Failed to claim issue"), + ) + } + + // Let the provider propagate the update before verifying. + delay(500) + + val verifiedIssue = issueTrackerProvider.queryIssues( + repository = repository, + query = IssueQuery(state = IssueState.Open, limit = 100), + ).getOrNull()?.find { it.number == issueNumber } + val finalStatus = IssueWorkflowStatus.fromLabels(verifiedIssue?.labels ?: emptyList()) + + return if (finalStatus != IssueWorkflowStatus.CLAIMED) { + Result.failure( + IllegalStateException("Race condition: another agent claimed the issue"), + ) + } else { + Result.success(Unit) + } + } catch (e: Exception) { + return Result.failure(e) + } + } + + /** + * Execute a claimed issue end-to-end: mark IN_PROGRESS → hand the issue + * to [agent] as a `Task.CodeChange` → reflect the outcome back as + * IN_REVIEW or BLOCKED labels. + * + * The agent receives the full issue body as the task description; it is + * responsible for planning and executing the code/git work via its + * standard `runTask` path. + */ + suspend fun workOnIssue( + issue: ExistingIssue, + agent: AutonomousAgent, + ): Result { + try { + updateIssueStatusSafely( + issueNumber = issue.number, + status = IssueWorkflowStatus.IN_PROGRESS, + comment = "Starting implementation", + ) + + val task = Task.CodeChange( + id = "issue-${issue.number}", + status = TaskStatus.Pending, + description = buildString { + appendLine("# ${issue.title}") + appendLine() + appendLine(issue.body) + appendLine() + appendLine("Issue: ${issue.url}") + appendLine() + appendLine("**Requirements:**") + appendLine("- Implement the feature/fix described above") + appendLine("- Create a feature branch") + appendLine("- Write tests if applicable") + appendLine("- Commit with conventional commit message") + appendLine("- Push to remote") + appendLine("- Create PR with 'Closes #${issue.number}'") + }, + ) + + return when (val outcome = agent.runTask(task)) { + is Outcome.Success -> Result.success( + "Issue #${issue.number} completed successfully. " + + "PR created and ready for review.", + ) + is Outcome.Failure -> { + updateIssueStatusSafely( + issueNumber = issue.number, + status = IssueWorkflowStatus.BLOCKED, + comment = "Execution failed: ${outcome.id}", + ) + Result.failure(Exception("Execution failed: ${outcome.id}")) + } + else -> { + updateIssueStatusSafely( + issueNumber = issue.number, + status = IssueWorkflowStatus.BLOCKED, + comment = "Unexpected outcome: ${outcome::class.simpleName}", + ) + Result.failure(Exception("Unexpected outcome: ${outcome::class.simpleName}")) + } + } + } catch (e: Exception) { + updateIssueStatusSafely( + issueNumber = issue.number, + status = IssueWorkflowStatus.BLOCKED, + comment = "Error: ${e.message}", + ) + return Result.failure(e) + } + } + + /** + * Replace the issue's workflow labels with the ones implied by [status]. + * + * Lower-level than [claimIssue] / [workOnIssue]; exposed for callers that + * need to drive the state machine directly (e.g. after a PR merge fires + * an external transition). + */ + suspend fun updateIssueStatus( + issueNumber: Int, + status: IssueWorkflowStatus, + comment: String? = null, + ): Result { + val currentIssue = issueTrackerProvider.queryIssues( + repository = repository, + query = IssueQuery(state = IssueState.Open, limit = 1), + ).getOrElse { emptyList() } + .find { it.number == issueNumber } + ?: return Result.failure( + IllegalArgumentException("Issue #$issueNumber not found"), + ) + + val newLabels = currentIssue.labels.toMutableSet().apply { + removeAll(status.removeLabels.toSet()) + addAll(status.addLabels) + } + return issueTrackerProvider.updateIssue( + repository = repository, + issueNumber = issueNumber, + update = IssueUpdate(labels = newLabels.toList()), + ) + } + + private suspend fun updateIssueStatusSafely( + issueNumber: Int, + status: IssueWorkflowStatus, + comment: String, + ) { + updateIssueStatus(issueNumber, status, comment) + .onFailure { error -> + println( + "Warning: Failed to update issue #$issueNumber status to ${status.name}: ${error.message}", + ) + } + } +} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/request/ExecutionContext.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/request/ExecutionContext.kt index a477e6e2..01f3936a 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/request/ExecutionContext.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/request/ExecutionContext.kt @@ -2,10 +2,12 @@ package link.socket.ampere.agents.execution.request import kotlinx.serialization.Serializable import link.socket.ampere.agents.domain.knowledge.Knowledge +import link.socket.ampere.agents.domain.reasoning.Plan import link.socket.ampere.agents.domain.task.Task import link.socket.ampere.agents.environment.workspace.ExecutionWorkspace import link.socket.ampere.agents.events.tickets.Ticket import link.socket.ampere.agents.execution.executor.ExecutorId +import link.socket.ampere.agents.execution.tools.ToolId import link.socket.ampere.agents.execution.tools.git.GitOperationRequest import link.socket.ampere.agents.execution.tools.issue.BatchIssueCreateRequest @@ -89,4 +91,43 @@ sealed interface ExecutionContext { val gitRequest: GitOperationRequest, override val knowledgeFromPastMemory: List = emptyList(), ) : ExecutionContext + + /** + * Context for the `plan_steps` tool — agent-neutral plan generation. + * + * Carries everything the tool's strategy needs to produce a structured plan: + * the task being planned for, the perceived ideas, recalled knowledge, the + * agent's role label, and a minimal description of the tools available so + * the LLM can populate `toolToUse` per step. + * + * The strategy fills [parsedPlan] from its LLM response; the tool's + * `execute()` reads it back out and wraps it in an outcome. + */ + @Serializable + data class Planning( + override val executorId: ExecutorId, + override val ticket: Ticket, + override val task: Task, + override val instructions: String, + val agentRole: String, + val ideaSummary: String, + val knowledgeSummary: String, + val availableToolDescriptors: List, + val parsedPlan: Plan? = null, + override val knowledgeFromPastMemory: List = emptyList(), + ) : ExecutionContext + + /** + * Serializable summary of a tool surfaced to the planning LLM. + * + * Avoids dragging the full `Tool<*>` hierarchy through the planning context, + * which would entangle execution-time tool registration with planning-time + * intent generation. + */ + @Serializable + data class ToolDescriptor( + val id: ToolId, + val name: String, + val description: String, + ) } diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/Tool.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/Tool.kt index a84063e5..09af9610 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/Tool.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/Tool.kt @@ -90,6 +90,10 @@ sealed interface Tool { * * @param Context The specific execution context this tool operates on. * @property executionFunction The actual function to invoke when this tool executes. + * @property parameterStrategy Optional tool-owned strategy for converting a + * high-level intent into the tool's call parameters via an LLM sub-call. The + * [ToolExecutionEngine][link.socket.ampere.agents.execution.ToolExecutionEngine] + * prefers a tool-owned strategy over any externally-registered one. */ @Serializable data class FunctionTool( @@ -105,6 +109,15 @@ data class FunctionTool( */ @Serializable(with = ExecutionFunctionSerializer::class) val executionFunction: suspend (ExecutionRequest) -> Outcome, + + /** + * Optional tool-owned [ParameterStrategy]. Marked `@Transient` because + * strategies are behavioural hooks that do not round-trip through + * serialization — they are reconstructed when the tool is rebuilt by its + * factory function. + */ + @Transient + override val parameterStrategy: ParameterStrategy? = null, ) : Tool { override suspend fun execute(executionRequest: ExecutionRequest): Outcome { diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/ToolAskHuman.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/ToolAskHuman.kt index e1ebc9b9..3a1e5fc2 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/ToolAskHuman.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/ToolAskHuman.kt @@ -2,23 +2,29 @@ package link.socket.ampere.agents.execution.tools import link.socket.ampere.agents.config.AgentActionAutonomy import link.socket.ampere.agents.domain.outcome.ExecutionOutcome +import link.socket.ampere.agents.execution.ParameterStrategy import link.socket.ampere.agents.execution.request.ExecutionContext expect suspend fun executeAskHuman( context: ExecutionContext.NoChanges, ): ExecutionOutcome.NoChanges +const val ASK_HUMAN_TOOL_ID: String = "ask_human" + /** * Creates a FunctionTool that asks a human for guidance. * * @param requiredAgentAutonomy The minimum autonomy level required to use this tool. + * @param parameterStrategy Optional tool-owned strategy for converting an + * unstructured intent into the structured escalation parameters. * @return A FunctionTool configured to escalate to humans. */ fun ToolAskHuman( requiredAgentAutonomy: AgentActionAutonomy, + parameterStrategy: ParameterStrategy? = null, ): FunctionTool { return FunctionTool( - id = ID, + id = ASK_HUMAN_TOOL_ID, name = NAME, description = DESCRIPTION, requiredAgentAutonomy = requiredAgentAutonomy, @@ -26,9 +32,8 @@ fun ToolAskHuman( // TODO: Handle execution request constraints executeAskHuman(executionRequest.context) }, + parameterStrategy = parameterStrategy, ) } - -private const val ID = "ask_human" private const val NAME = "Ask a Human" private const val DESCRIPTION = "Escalates uncertainty to human for guidance." diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/ToolCreateIssues.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/ToolCreateIssues.kt index f0769a8b..a586b75c 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/ToolCreateIssues.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/ToolCreateIssues.kt @@ -2,6 +2,7 @@ package link.socket.ampere.agents.execution.tools import link.socket.ampere.agents.config.AgentActionAutonomy import link.socket.ampere.agents.domain.outcome.ExecutionOutcome +import link.socket.ampere.agents.execution.ParameterStrategy import link.socket.ampere.agents.execution.request.ExecutionContext /** @@ -24,11 +25,14 @@ expect suspend fun executeCreateIssues( * (can be closed/deleted) but should notify stakeholders. * @return A FunctionTool configured to create issues. */ +const val CREATE_ISSUES_TOOL_ID: String = "create_issues" + fun ToolCreateIssues( requiredAgentAutonomy: AgentActionAutonomy = AgentActionAutonomy.ACT_WITH_NOTIFICATION, + parameterStrategy: ParameterStrategy? = null, ): FunctionTool { return FunctionTool( - id = ID, + id = CREATE_ISSUES_TOOL_ID, name = NAME, description = DESCRIPTION, requiredAgentAutonomy = requiredAgentAutonomy, @@ -36,6 +40,7 @@ fun ToolCreateIssues( // TODO: Handle execution request constraints executeCreateIssues(executionRequest.context) }, + parameterStrategy = parameterStrategy, ) } diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/ToolReadCodeFile.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/ToolReadCodeFile.kt new file mode 100644 index 00000000..188ddbb9 --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/ToolReadCodeFile.kt @@ -0,0 +1,42 @@ +package link.socket.ampere.agents.execution.tools + +import link.socket.ampere.agents.config.AgentActionAutonomy +import link.socket.ampere.agents.definition.code.CodeParams +import link.socket.ampere.agents.execution.ParameterStrategy +import link.socket.ampere.agents.execution.request.ExecutionContext + +const val READ_CODE_FILE_TOOL_ID: String = "read_code_file" + +/** + * Creates a FunctionTool that reads code files from the workspace. + * + * Wraps the same platform-side `executeReadCodebase` implementation as + * [ToolReadCodebase] but exposes the tool under the canonical + * `read_code_file` id that [link.socket.ampere.agents.domain.cognition.sparks.RoleSpark.Code] + * already references, and ships with the [CodeParams.CodeReading] + * parameter strategy attached so an agent that wants the tool does not + * need to register a strategy separately. + * + * @param requiredAgentAutonomy The minimum autonomy level required to + * use this tool. + * @param parameterStrategy Override the default code-reading strategy + * (e.g. for testing). Pass `null` to disable param generation. + */ +fun ToolReadCodeFile( + requiredAgentAutonomy: AgentActionAutonomy = AgentActionAutonomy.FULLY_AUTONOMOUS, + parameterStrategy: ParameterStrategy? = CodeParams.CodeReading(), +): FunctionTool { + return FunctionTool( + id = READ_CODE_FILE_TOOL_ID, + name = NAME, + description = DESCRIPTION, + requiredAgentAutonomy = requiredAgentAutonomy, + executionFunction = { executionRequest -> + executeReadCodebase(executionRequest.context) + }, + parameterStrategy = parameterStrategy, + ) +} + +private const val NAME = "Read Code File" +private const val DESCRIPTION = "Reads one or more code files from the current workspace." diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/ToolWriteCodeFile.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/ToolWriteCodeFile.kt index 535c4236..9720a5d5 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/ToolWriteCodeFile.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/ToolWriteCodeFile.kt @@ -1,24 +1,36 @@ package link.socket.ampere.agents.execution.tools import link.socket.ampere.agents.config.AgentActionAutonomy +import link.socket.ampere.agents.definition.code.CodeParams import link.socket.ampere.agents.domain.outcome.ExecutionOutcome +import link.socket.ampere.agents.execution.ParameterStrategy import link.socket.ampere.agents.execution.request.ExecutionContext expect suspend fun executeWriteCodeFile( context: ExecutionContext.Code.WriteCode, ): ExecutionOutcome.CodeChanged +const val WRITE_CODE_FILE_TOOL_ID: String = "write_code_file" + /** * Creates a FunctionTool that writes code files to the workspace. * - * @param requiredAgentAutonomy The minimum autonomy level required to use this tool. - * @return A FunctionTool configured to write code files. + * The tool ships with [CodeParams.CodeWriting] as its + * [ParameterStrategy] by default so the sub-prompt that converts a + * high-level intent into "files to write" lives with the tool rather + * than being externally registered by every agent that wants the tool. + * + * @param requiredAgentAutonomy The minimum autonomy level required to + * use this tool. + * @param parameterStrategy Override the default code-writing strategy + * (e.g. for testing). Pass `null` to disable param generation. */ fun ToolWriteCodeFile( requiredAgentAutonomy: AgentActionAutonomy, + parameterStrategy: ParameterStrategy? = CodeParams.CodeWriting(), ): FunctionTool { return FunctionTool( - id = ID, + id = WRITE_CODE_FILE_TOOL_ID, name = NAME, description = DESCRIPTION, requiredAgentAutonomy = requiredAgentAutonomy, @@ -26,9 +38,9 @@ fun ToolWriteCodeFile( // TODO: Handle execution request constraints executeWriteCodeFile(executionRequest.context) }, + parameterStrategy = parameterStrategy, ) } -private const val ID = "write_code_file" private const val NAME = "Write Code File" private const val DESCRIPTION = "Writes a code file in the current workspace." diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/git/GitParams.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/git/GitParams.kt new file mode 100644 index 00000000..3152a6bc --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/git/GitParams.kt @@ -0,0 +1,130 @@ +package link.socket.ampere.agents.execution.tools.git + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import link.socket.ampere.agents.execution.ParameterStrategy +import link.socket.ampere.agents.execution.request.ExecutionContext +import link.socket.ampere.agents.execution.request.ExecutionRequest +import link.socket.ampere.agents.execution.tools.Tool + +/** + * Parameter strategies for git tools. + * + * Each git tool that the planner can nominate via `toolToUse` ships with the + * strategy that converts the plan step's intent into the appropriate + * [GitOperationRequest] sub-record. Strategies are deliberately small — + * they translate the step's intent string (and any context already on the + * request) into structured parameters; they do not contain any + * agent-specific routing or fallback logic. + */ +sealed class GitParams { + + /** + * Strategy for the `git_commit` tool. Asks the LLM to produce a commit + * message and an optional file list / issue-number reference from the + * plan step's intent and current workspace state. + */ + class Commit( + private val repositoryHint: String = ".", + ) : ParameterStrategy { + + override val systemMessage: String = + "You are a git commit message author. Produce a concise, " + + "imperative-mood commit message and identify which files " + + "(and optionally which issue number) should accompany the " + + "commit. Respond only with valid JSON." + + override val maxTokens: Int = 600 + + override fun buildPrompt( + tool: Tool<*>, + request: ExecutionRequest<*>, + intent: String, + ): String { + val existingFiles = (request.context as? ExecutionContext.GitOperation) + ?.gitRequest?.stageFiles ?: emptyList() + + return buildString { + appendLine("You are preparing a git commit for the following intent:") + appendLine() + appendLine(intent) + appendLine() + if (existingFiles.isNotEmpty()) { + appendLine("Files already staged in this step's context:") + existingFiles.forEach { appendLine("- $it") } + appendLine() + } + appendLine("Respond with a JSON object of exactly this shape:") + appendLine( + """ +{ + "message": "", + "files": ["path/relative/to/repo", "..."], + "issueNumber": +} + """.trimIndent(), + ) + appendLine() + appendLine("- 'message' is required and must not be empty.") + appendLine("- 'files' may be empty when committing what's already staged.") + appendLine("- 'issueNumber' may be null when no issue is being closed.") + appendLine() + appendLine("Respond ONLY with the JSON object, no other text.") + } + } + + override fun parseAndEnrichRequest( + jsonResponse: String, + originalRequest: ExecutionRequest<*>, + ): ExecutionRequest<*> { + val cleaned = jsonResponse + .trim() + .removePrefix("```json") + .removePrefix("```JSON") + .removePrefix("```") + .removeSuffix("```") + .trim() + + val obj = json.parseToJsonElement(cleaned).jsonObject + val message = obj["message"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } + ?: error("git_commit strategy: response missing non-empty 'message'") + val files = obj["files"]?.jsonArray + ?.map { it.jsonPrimitive.content } + ?: emptyList() + val issueNumber = obj["issueNumber"]?.jsonPrimitive?.intOrNull + + val originalContext = originalRequest.context + val priorRepo = (originalContext as? ExecutionContext.GitOperation) + ?.gitRequest?.repository + ?: repositoryHint + + val newGitRequest = GitOperationRequest( + repository = priorRepo, + commit = CommitRequest( + message = message, + files = files, + issueNumber = issueNumber, + ), + ) + + return ExecutionRequest( + context = ExecutionContext.GitOperation( + executorId = originalContext.executorId, + ticket = originalContext.ticket, + task = originalContext.task, + instructions = originalContext.instructions, + gitRequest = newGitRequest, + knowledgeFromPastMemory = originalContext.knowledgeFromPastMemory, + ), + constraints = originalRequest.constraints, + ) + } + + private companion object { + private val json = Json { ignoreUnknownKeys = true } + } + } +} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/git/GitTools.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/git/GitTools.kt index df655eda..c62dd208 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/git/GitTools.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/git/GitTools.kt @@ -2,6 +2,7 @@ package link.socket.ampere.agents.execution.tools.git import link.socket.ampere.agents.config.AgentActionAutonomy import link.socket.ampere.agents.domain.outcome.ExecutionOutcome +import link.socket.ampere.agents.execution.ParameterStrategy import link.socket.ampere.agents.execution.request.ExecutionContext import link.socket.ampere.agents.execution.tools.FunctionTool @@ -104,6 +105,7 @@ Supports conventional commit format and issue references. */ fun ToolCommit( requiredAgentAutonomy: AgentActionAutonomy = AgentActionAutonomy.ACT_WITH_NOTIFICATION, + parameterStrategy: ParameterStrategy? = GitParams.Commit(), ): FunctionTool { return FunctionTool( id = COMMIT_ID, @@ -116,6 +118,7 @@ fun ToolCommit( } executeGitOperation(executionRequest.context) }, + parameterStrategy = parameterStrategy, ) } diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/planning/ToolPlanSteps.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/planning/ToolPlanSteps.kt new file mode 100644 index 00000000..ddb98167 --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/execution/tools/planning/ToolPlanSteps.kt @@ -0,0 +1,255 @@ +package link.socket.ampere.agents.execution.tools.planning + +import kotlinx.datetime.Clock +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import link.socket.ampere.agents.config.AgentActionAutonomy +import link.socket.ampere.agents.domain.error.ExecutionError +import link.socket.ampere.agents.domain.expectation.Expectations +import link.socket.ampere.agents.domain.outcome.ExecutionOutcome +import link.socket.ampere.agents.domain.outcome.Outcome +import link.socket.ampere.agents.domain.reasoning.DefaultTaskFactory +import link.socket.ampere.agents.domain.reasoning.LLMResponseParser +import link.socket.ampere.agents.domain.reasoning.Plan +import link.socket.ampere.agents.domain.reasoning.TaskFactory +import link.socket.ampere.agents.domain.task.Task +import link.socket.ampere.agents.execution.ParameterStrategy +import link.socket.ampere.agents.execution.request.ExecutionContext +import link.socket.ampere.agents.execution.request.ExecutionRequest +import link.socket.ampere.agents.execution.tools.FunctionTool +import link.socket.ampere.agents.execution.tools.Tool + +/** + * The tool id under which `ToolPlanSteps` registers itself. + * + * Surfaced as a public constant so agents can reference it when describing + * the tool in their planning instructions without hard-coding the string. + */ +const val PLAN_STEPS_TOOL_ID: String = "plan_steps" + +private const val PLAN_STEPS_NAME = "Plan Steps" +private const val PLAN_STEPS_DESCRIPTION = + "Decomposes a task into an ordered list of executable steps. " + + "Each step nominates exactly one tool (or none) so the agent's " + + "executor can route by id without keyword fallback." + +/** + * Agent-neutral planning tool. + * + * The JSON shape and parsing rules live with the tool's [PlanStepsStrategy], + * not on any agent profile or spark. Every `SparkBasedAgent` includes this + * tool by default so a freshly-stacked agent already knows how to produce a + * structured plan — independent of which language, framework, or domain + * sparks have been layered on top. + * + * @param taskFactory how to materialise each plan step into a concrete + * [Task] subtype. Defaults to [DefaultTaskFactory] (which emits + * `Task.CodeChange`); agents that need a different task shape pass in + * their own factory. + */ +fun ToolPlanSteps( + taskFactory: TaskFactory = DefaultTaskFactory, + requiredAgentAutonomy: AgentActionAutonomy = AgentActionAutonomy.FULLY_AUTONOMOUS, +): FunctionTool = FunctionTool( + id = PLAN_STEPS_TOOL_ID, + name = PLAN_STEPS_NAME, + description = PLAN_STEPS_DESCRIPTION, + requiredAgentAutonomy = requiredAgentAutonomy, + parameterStrategy = PlanStepsStrategy(taskFactory), + executionFunction = { request -> + val startTime = Clock.System.now() + val context = request.context + val plan = context.parsedPlan + if (plan == null) { + ExecutionOutcome.Planning.Failure( + executorId = context.executorId, + ticketId = context.ticket.id, + taskId = context.task.id, + executionStartTimestamp = startTime, + executionEndTimestamp = Clock.System.now(), + error = ExecutionError( + type = ExecutionError.Type.UNEXPECTED, + message = "plan_steps was invoked without a parsed plan — " + + "the parameter strategy must populate Planning.parsedPlan " + + "before execute() runs.", + ), + ) + } else { + ExecutionOutcome.Planning.Success( + executorId = context.executorId, + ticketId = context.ticket.id, + taskId = context.task.id, + executionStartTimestamp = startTime, + executionEndTimestamp = Clock.System.now(), + plan = plan, + ) + } + }, +) + +/** + * Parameter strategy for the `plan_steps` tool. + * + * Owns the planning prompt and the JSON schema that the planning LLM must + * produce. The schema is **load-bearing**: the executor routes steps by + * their `toolToUse` id with no keyword fallback, so the strategy emphasises + * exact tool-id usage and fails fast when the response is malformed. + */ +class PlanStepsStrategy( + private val taskFactory: TaskFactory = DefaultTaskFactory, +) : ParameterStrategy { + + override val systemMessage: String = + "You are an autonomous agent planning system. " + + "Generate structured execution plans. Respond only with valid JSON." + + override val maxTokens: Int = 1000 + + override fun buildPrompt( + tool: Tool<*>, + request: ExecutionRequest<*>, + intent: String, + ): String { + val context = request.context as? ExecutionContext.Planning + ?: error( + "plan_steps requires ExecutionContext.Planning, got " + + request.context::class.simpleName, + ) + + return buildString { + appendLine( + "You are the planning module of an autonomous ${context.agentRole} agent.", + ) + appendLine( + "Your task is to create a concrete, executable plan to accomplish the given task.", + ) + appendLine() + appendLine("Task: $intent") + appendLine() + appendLine("Insights from Perception:") + appendLine(context.ideaSummary.ifBlank { "No insights available from perception phase." }) + appendLine() + appendLine("Past Knowledge:") + appendLine(context.knowledgeSummary.ifBlank { "No relevant past knowledge available." }) + appendLine() + + if (context.availableToolDescriptors.isNotEmpty()) { + appendLine("Available Tools:") + context.availableToolDescriptors.forEach { descriptor -> + appendLine("- ${descriptor.id}: ${descriptor.description}") + } + appendLine() + } + + appendLine("Create a step-by-step plan where each step is a concrete task that can be executed.") + appendLine("Each step must:") + appendLine("1. Have a clear, actionable description.") + appendLine( + "2. Populate `toolToUse` with the exact tool id from the available-tools list, " + + "or null when the step is pure reasoning with no tool invocation.", + ) + appendLine("3. Be sequentially ordered with `requiresPreviousStep` flagging dependencies.") + appendLine() + appendLine("For simple tasks, create a 1-2 step plan.") + appendLine("For complex tasks, break down into logical phases (3-5 steps typically).") + appendLine("Avoid excessive granularity - focus on meaningful phases of work.") + appendLine() + appendLine("Format your response as a JSON object with exactly this shape:") + appendLine( + """ +{ + "steps": [ + { + "description": "what this step accomplishes", + "toolToUse": "", + "requiresPreviousStep": true/false + } + ], + "estimatedComplexity": 1-10 +} + """.trimIndent(), + ) + appendLine() + appendLine( + "Use only tool ids that appeared in the Available Tools list above. " + + "The executor will fail fast on unrecognised ids.", + ) + appendLine("Respond ONLY with the JSON object, no other text.") + } + } + + override fun parseAndEnrichRequest( + jsonResponse: String, + originalRequest: ExecutionRequest<*>, + ): ExecutionRequest<*> { + val originalContext = originalRequest.context as? ExecutionContext.Planning + ?: error( + "plan_steps requires ExecutionContext.Planning, got " + + originalRequest.context::class.simpleName, + ) + + val cleaned = LLMResponseParser.cleanJsonResponse(jsonResponse) + val planJson = LLMResponseParser.parseJsonObject(cleaned) + + val stepsArray = planJson["steps"]?.jsonArray + ?: error("plan_steps response is missing 'steps' array") + if (stepsArray.isEmpty()) { + error("plan_steps response contains an empty 'steps' array") + } + + val complexity = LLMResponseParser.getInt(planJson, "estimatedComplexity", 5) + val originalTask = originalContext.task + + val planTasks: List = stepsArray.mapIndexed { index, element -> + val stepObj = element.jsonObject + val description = stepObj["description"]?.jsonPrimitive?.content + ?: "Step ${index + 1}" + val toolToUse = stepObj["toolToUse"]?.jsonPrimitive?.content + taskFactory.create( + id = "step-${index + 1}-${originalTask.id}", + description = description, + toolId = toolToUse, + originalTask = originalTask, + ) + } + + val plan: Plan = Plan.ForTask( + task = originalTask, + tasks = planTasks, + estimatedComplexity = complexity, + expectations = Expectations.blank, + ) + + return ExecutionRequest( + context = originalContext.copy(parsedPlan = plan), + constraints = originalRequest.constraints, + ) + } +} + +/** + * Pure-helper conversion from a set of `Tool<*>` to the serialisable + * [ExecutionContext.ToolDescriptor] view the planning context expects. + * + * Lives in the same file as the planning tool so callers can stage a + * `Planning` request without depending on planning internals. + */ +fun Iterable>.toPlanningDescriptors(): List = + map { tool -> + ExecutionContext.ToolDescriptor( + id = tool.id, + name = tool.name, + description = tool.description, + ) + } + +/** + * Coerce a planning [Outcome] back to a [Plan]. Returns [Plan.Blank] when the + * outcome is anything other than a planning success, which callers can treat + * as a fail-safe (the agent's loop will fall through to a no-op execution). + */ +fun Outcome.planOrBlank(): Plan = when (this) { + is ExecutionOutcome.Planning.Success -> plan + else -> Plan.blank +} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/arc/ChargePhase.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/arc/ChargePhase.kt index 9236adb9..b8e7bcfa 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/arc/ChargePhase.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/arc/ChargePhase.kt @@ -3,6 +3,7 @@ package link.socket.ampere.domain.arc import link.socket.ampere.agents.definition.Agent import link.socket.ampere.agents.definition.SparkAgentFactory import link.socket.ampere.agents.definition.SparkBasedAgent +import link.socket.ampere.agents.definition.code.CodeState import link.socket.ampere.agents.domain.cognition.CognitiveAffinity import link.socket.ampere.agents.domain.cognition.Spark import link.socket.ampere.agents.domain.cognition.sparks.LanguageSpark @@ -287,7 +288,7 @@ internal class GoalTreeBuilder { internal class ArcAgentSpawner( private val agentFactory: SparkAgentFactory = SparkAgentFactory(), ) { - fun spawn(arcConfig: ArcConfig, projectContext: ProjectContext): List { + fun spawn(arcConfig: ArcConfig, projectContext: ProjectContext): List> { val projectSpark = projectContext.toProjectSpark() return arcConfig.agents.map { agentConfig -> @@ -299,12 +300,12 @@ internal class ArcAgentSpawner( affinity = affinity, ) - agent.spark(projectSpark) - agent.spark(roleSpark) + agent.spark>(projectSpark) + agent.spark>(roleSpark) agentConfig.sparks .map { AdditionalSparkResolver.resolve(it) } - .forEach { spark -> agent.spark(spark) } + .forEach { spark -> agent.spark>(spark) } agent } diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/TestHelpers.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/TestHelpers.kt index 48b4ad17..ab51259f 100644 --- a/ampere-core/src/commonTest/kotlin/link/socket/ampere/TestHelpers.kt +++ b/ampere-core/src/commonTest/kotlin/link/socket/ampere/TestHelpers.kt @@ -4,8 +4,7 @@ import kotlin.time.Duration.Companion.seconds import kotlinx.datetime.Clock import link.socket.ampere.agents.config.AgentConfiguration import link.socket.ampere.agents.definition.AgentId -import link.socket.ampere.agents.definition.ProductAgent -import link.socket.ampere.agents.definition.QualityAgent +import link.socket.ampere.agents.definition.SparkBasedAgent import link.socket.ampere.agents.definition.product.ProductState import link.socket.ampere.agents.definition.qa.QualityState import link.socket.ampere.agents.domain.knowledge.KnowledgeEntry @@ -146,14 +145,13 @@ fun stubProductManagerState( ) fun stubProductManagerAgent( - ticketOrchestrator: TicketOrchestrator, - initialState: ProductState = stubProductManagerState(), + @Suppress("UNUSED_PARAMETER") ticketOrchestrator: TicketOrchestrator, + @Suppress("UNUSED_PARAMETER") initialState: ProductState = stubProductManagerState(), agentConfiguration: AgentConfiguration = stubAgentConfiguration(), -): ProductAgent = - ProductAgent( - initialState = initialState, - agentConfiguration = agentConfiguration, - ticketOrchestrator = ticketOrchestrator, +): SparkBasedAgent = + SparkBasedAgent.Product( + agentId = "stub-product-agent", + aiConfiguration = agentConfiguration.aiConfiguration, ) fun stubQualityAssuranceState( @@ -168,10 +166,10 @@ fun stubQualityAssuranceState( ) fun stubQualityAssuranceAgent( - initialState: QualityState = stubQualityAssuranceState(), + @Suppress("UNUSED_PARAMETER") initialState: QualityState = stubQualityAssuranceState(), agentConfiguration: AgentConfiguration = stubAgentConfiguration(), -): QualityAgent = - QualityAgent( - initialState = initialState, - agentConfiguration = agentConfiguration, +): SparkBasedAgent = + SparkBasedAgent.Quality( + agentId = "stub-quality-agent", + aiConfiguration = agentConfiguration.aiConfiguration, ) diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/definition/CodeAgentIssueDiscoveryTest.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/definition/CodeAgentIssueDiscoveryTest.kt deleted file mode 100644 index 7d6688d0..00000000 --- a/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/definition/CodeAgentIssueDiscoveryTest.kt +++ /dev/null @@ -1,439 +0,0 @@ -package link.socket.ampere.agents.definition - -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.runTest -import link.socket.ampere.agents.config.AgentActionAutonomy -import link.socket.ampere.agents.definition.code.CodeState -import link.socket.ampere.agents.execution.request.ExecutionContext -import link.socket.ampere.agents.execution.tools.FunctionTool -import link.socket.ampere.integrations.issues.ExistingIssue -import link.socket.ampere.integrations.issues.IssueQuery -import link.socket.ampere.integrations.issues.IssueState -import link.socket.ampere.integrations.issues.IssueTrackerProvider -import link.socket.ampere.integrations.issues.IssueUpdate -import link.socket.ampere.stubAgentConfiguration - -/** - * Tests for CodeAgent issue discovery functionality. - * - * Verifies that CodeAgent can: - * - Query GitHub for assigned issues - * - Query GitHub for available unassigned issues - * - Include issue information in perception context - * - Handle missing provider gracefully - */ -class CodeAgentIssueDiscoveryTest { - - private class MockIssueTrackerProvider : IssueTrackerProvider { - override val providerId: String = "mock-github" - override val displayName: String = "Mock GitHub" - - var assignedIssues: List = emptyList() - var availableIssues: List = emptyList() - var lastQuery: IssueQuery? = null - var shouldFail: Boolean = false - - override suspend fun validateConnection(): Result = Result.success(Unit) - - override suspend fun createIssue( - repository: String, - request: link.socket.ampere.agents.execution.tools.issue.IssueCreateRequest, - resolvedDependencies: Map, - ): Result { - error("Not implemented for this test") - } - - override suspend fun setParentRelationship( - repository: String, - childIssueNumber: Int, - parentIssueNumber: Int, - ): Result { - error("Not implemented for this test") - } - - override suspend fun queryIssues( - repository: String, - query: IssueQuery, - ): Result> { - lastQuery = query - - if (shouldFail) { - return Result.failure(Exception("Mock query failure")) - } - - // Return assigned or available issues based on query - return Result.success( - when { - query.assignee == "CodeWriterAgent" -> assignedIssues - query.assignee == null -> availableIssues - else -> emptyList() - }, - ) - } - - override suspend fun updateIssue( - repository: String, - issueNumber: Int, - update: IssueUpdate, - ): Result { - error("Not implemented for this test") - } - } - - private fun createTestAgent( - issueTrackerProvider: IssueTrackerProvider? = null, - repository: String? = null, - ): CodeAgent { - val mockWriteTool = FunctionTool( - id = "write_code_file", - name = "write_code_file", - description = "Write code to a file", - requiredAgentAutonomy = AgentActionAutonomy.ACT_WITH_NOTIFICATION, - executionFunction = { error("Not used in this test") }, - ) - - return CodeAgent( - agentConfiguration = stubAgentConfiguration(), - toolWriteCodeFile = mockWriteTool, - coroutineScope = CoroutineScope(Dispatchers.Default), - issueTrackerProvider = issueTrackerProvider, - repository = repository, - ) - } - - @Test - fun `queryAssignedIssues returns empty when provider is null`() = runTest { - val agent = createTestAgent( - issueTrackerProvider = null, - repository = "test/repo", - ) - - val result = agent.queryAssignedIssues() - - assertTrue(result.isEmpty(), "Should return empty list when provider is null") - } - - @Test - fun `queryAssignedIssues returns empty when repository is null`() = runTest { - val mockProvider = MockIssueTrackerProvider() - val agent = createTestAgent( - issueTrackerProvider = mockProvider, - repository = null, - ) - - val result = agent.queryAssignedIssues() - - assertTrue(result.isEmpty(), "Should return empty list when repository is null") - } - - @Test - fun `queryAssignedIssues queries with correct parameters`() = runTest { - val mockProvider = MockIssueTrackerProvider() - mockProvider.assignedIssues = listOf( - ExistingIssue( - number = 123, - title = "Fix authentication bug", - body = "Auth is broken in production", - state = IssueState.Open, - labels = listOf("bug", "priority-high"), - url = "https://github.com/test/repo/issues/123", - ), - ) - - val agent = createTestAgent( - issueTrackerProvider = mockProvider, - repository = "test/repo", - ) - - val result = agent.queryAssignedIssues() - - // Verify query parameters - val lastQuery = assertNotNull(mockProvider.lastQuery, "Query should have been called") - assertTrue(lastQuery.state == IssueState.Open, "Should query for open issues") - assertTrue(lastQuery.assignee == "CodeWriterAgent", "Should query for CodeWriterAgent") - assertTrue(lastQuery.limit == 20, "Should limit to 20 results") - - // Verify results - assertTrue(result.size == 1, "Should return 1 assigned issue") - } - - @Test - fun `queryAssignedIssues returns empty on provider failure`() = runTest { - val mockProvider = MockIssueTrackerProvider() - mockProvider.shouldFail = true - - val agent = createTestAgent( - issueTrackerProvider = mockProvider, - repository = "test/repo", - ) - - val result = agent.queryAssignedIssues() - - assertTrue(result.isEmpty(), "Should return empty list on failure") - } - - @Test - fun `queryAvailableIssues returns empty when provider is null`() = runTest { - val agent = createTestAgent( - issueTrackerProvider = null, - repository = "test/repo", - ) - - val result = agent.queryAvailableIssues() - - assertTrue(result.isEmpty(), "Should return empty list when provider is null") - } - - @Test - fun `queryAvailableIssues queries with correct parameters`() = runTest { - val mockProvider = MockIssueTrackerProvider() - mockProvider.availableIssues = listOf( - ExistingIssue( - number = 456, - title = "Add new feature", - body = "Implement user preferences", - state = IssueState.Open, - labels = listOf("code", "feature"), - url = "https://github.com/test/repo/issues/456", - ), - ) - - val agent = createTestAgent( - issueTrackerProvider = mockProvider, - repository = "test/repo", - ) - - val result = agent.queryAvailableIssues() - - // Verify query parameters - val lastQuery = assertNotNull(mockProvider.lastQuery, "Query should have been called") - assertTrue(lastQuery.state == IssueState.Open, "Should query for open issues") - assertTrue(lastQuery.assignee == null, "Should query for unassigned issues") - assertTrue(lastQuery.labels.contains("code"), "Should filter by 'code' label") - assertTrue(lastQuery.labels.contains("task"), "Should filter by 'task' label") - assertTrue(lastQuery.limit == 10, "Should limit to 10 results") - - // Verify results - assertTrue(result.size == 1, "Should return 1 available issue") - } - - @Test - fun `perception context includes assigned issues section when issues exist`() = runTest { - val mockProvider = MockIssueTrackerProvider() - mockProvider.assignedIssues = listOf( - ExistingIssue( - number = 123, - title = "Fix authentication bug", - body = "Auth is broken in production. Need to update JWT validation.", - state = IssueState.Open, - labels = listOf("bug", "priority-high"), - url = "https://github.com/test/repo/issues/123", - ), - ExistingIssue( - number = 124, - title = "Refactor database layer", - body = "", - state = IssueState.Open, - labels = listOf("refactor"), - url = "https://github.com/test/repo/issues/124", - ), - ) - - val agent = createTestAgent( - issueTrackerProvider = mockProvider, - repository = "test/repo", - ) - - val context = agent.buildPerceptionContext(CodeState.blank) - - assertTrue(context.isNotBlank(), "Context should not be blank") - assertTrue(context.contains("Assigned Issues"), "Should have Assigned Issues section") - assertTrue(context.contains("#123: Fix authentication bug"), "Should include issue #123") - assertTrue(context.contains("#124: Refactor database layer"), "Should include issue #124") - assertTrue(context.contains("bug, priority-high"), "Should include labels") - assertTrue(context.contains("https://github.com/test/repo/issues/123"), "Should include URL") - assertTrue(context.contains("Auth is broken in production"), "Should include description preview") - } - - @Test - fun `perception context includes available issues section when issues exist`() = runTest { - val mockProvider = MockIssueTrackerProvider() - mockProvider.availableIssues = listOf( - ExistingIssue( - number = 456, - title = "Add dark mode", - body = "Users want dark mode", - state = IssueState.Open, - labels = listOf("code", "feature"), - url = "https://github.com/test/repo/issues/456", - ), - ) - - val agent = createTestAgent( - issueTrackerProvider = mockProvider, - repository = "test/repo", - ) - - val context = agent.buildPerceptionContext(CodeState.blank) - - assertTrue(context.isNotBlank(), "Context should not be blank") - assertTrue( - context.contains("Available Issues (Unassigned)"), - "Should have Available Issues section", - ) - assertTrue(context.contains("#456: Add dark mode"), "Should include issue #456") - assertTrue(context.contains("code, feature"), "Should include labels") - } - - @Test - fun `perception context limits available issues to 5 with overflow message`() = runTest { - val mockProvider = MockIssueTrackerProvider() - mockProvider.availableIssues = (1..8).map { i -> - ExistingIssue( - number = i, - title = "Issue $i", - body = "Description $i", - state = IssueState.Open, - labels = listOf("code"), - url = "https://github.com/test/repo/issues/$i", - ) - } - - val agent = createTestAgent( - issueTrackerProvider = mockProvider, - repository = "test/repo", - ) - - val context = agent.buildPerceptionContext(CodeState.blank) - - assertTrue(context.isNotBlank(), "Context should not be blank") - assertTrue(context.contains("#1: Issue 1"), "Should include first issue") - assertTrue(context.contains("#5: Issue 5"), "Should include fifth issue") - assertTrue(context.contains("... and 3 more available"), "Should show overflow message") - assertFalse(context.contains("#6: Issue 6"), "Should not include sixth issue") - } - - @Test - fun `perception context excludes issue sections when provider is null`() = runTest { - val agent = createTestAgent( - issueTrackerProvider = null, - repository = "test/repo", - ) - - val context = agent.buildPerceptionContext(CodeState.blank) - - assertTrue(context.isNotBlank(), "Context should not be blank") - assertFalse( - context.contains("Assigned Issues"), - "Should not have Assigned Issues section", - ) - assertFalse( - context.contains("Available Issues"), - "Should not have Available Issues section", - ) - } - - @Test - fun `perception context excludes issue sections when repository is null`() = runTest { - val agent = createTestAgent( - issueTrackerProvider = MockIssueTrackerProvider(), - repository = null, - ) - - val context = agent.buildPerceptionContext(CodeState.blank) - - assertTrue(context.isNotBlank(), "Context should not be blank") - assertFalse( - context.contains("Assigned Issues"), - "Should not have Assigned Issues section", - ) - assertFalse( - context.contains("Available Issues"), - "Should not have Available Issues section", - ) - } - - @Test - fun `perception context excludes issue sections when queries return empty`() = runTest { - val mockProvider = MockIssueTrackerProvider() - // Empty lists by default - - val agent = createTestAgent( - issueTrackerProvider = mockProvider, - repository = "test/repo", - ) - - val context = agent.buildPerceptionContext(CodeState.blank) - - assertTrue(context.isNotBlank(), "Context should not be blank") - assertFalse( - context.contains("Assigned Issues"), - "Should not have Assigned Issues section when empty", - ) - assertFalse( - context.contains("Available Issues"), - "Should not have Available Issues section when empty", - ) - } - - @Test - fun `perception context handles long issue descriptions with preview`() = runTest { - val mockProvider = MockIssueTrackerProvider() - val longDescription = "This is a very long issue description ".repeat(10) // > 100 chars - mockProvider.assignedIssues = listOf( - ExistingIssue( - number = 999, - title = "Long description issue", - body = longDescription, - state = IssueState.Open, - labels = listOf("bug"), - url = "https://github.com/test/repo/issues/999", - ), - ) - - val agent = createTestAgent( - issueTrackerProvider = mockProvider, - repository = "test/repo", - ) - - val context = agent.buildPerceptionContext(CodeState.blank) - - assertTrue(context.isNotBlank(), "Context should not be blank") - assertTrue(context.contains("Description: This is a very long"), "Should include preview") - assertTrue(context.contains("..."), "Should include ellipsis for long description") - } - - @Test - fun `perception context handles issue with blank description`() = runTest { - val mockProvider = MockIssueTrackerProvider() - mockProvider.assignedIssues = listOf( - ExistingIssue( - number = 100, - title = "Issue with no description", - body = "", - state = IssueState.Open, - labels = listOf("task"), - url = "https://github.com/test/repo/issues/100", - ), - ) - - val agent = createTestAgent( - issueTrackerProvider = mockProvider, - repository = "test/repo", - ) - - val context = agent.buildPerceptionContext(CodeState.blank) - - assertTrue(context.isNotBlank(), "Context should not be blank") - assertTrue(context.contains("#100: Issue with no description"), "Should include issue") - assertFalse( - context.contains("Description:"), - "Should not include description line when blank", - ) - } -} diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/LanguageSparkKotlinTest.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/LanguageSparkKotlinTest.kt new file mode 100644 index 00000000..c4fceca9 --- /dev/null +++ b/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/LanguageSparkKotlinTest.kt @@ -0,0 +1,57 @@ +package link.socket.ampere.agents.domain.cognition.sparks + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Verifies that `LanguageSpark.Kotlin` carries the Kotlin-specific guidance + * that the language-neutral `code-agent.spark.md` deliberately omits — both + * the always-on `promptContribution` and the per-phase contributions that + * appear alongside `## When Planning` / `## When Executing` once the spark + * is on the stack. + */ +class LanguageSparkKotlinTest { + + @Test + fun `always-on contribution covers package-from-path convention`() { + val contribution = LanguageSpark.Kotlin.promptContribution + assertTrue( + contribution.contains("src/commonMain/kotlin"), + "Kotlin spark should cover the multiplatform source-root convention", + ) + assertTrue( + contribution.contains("package link.socket.ampere"), + "Kotlin spark should illustrate the package-from-path convention with a concrete example", + ) + } + + @Test + fun `always-on contribution rules out incomplete generations`() { + val contribution = LanguageSpark.Kotlin.promptContribution + assertTrue( + contribution.contains("TODO", ignoreCase = false), + "Kotlin spark should explicitly disallow TODO placeholders in generated code", + ) + } + + @Test + fun `per-phase contributions exist for PLAN and EXECUTE`() { + val contributions = LanguageSpark.Kotlin.phaseContributions + assertEquals( + setOf(CognitivePhase.PLAN, CognitivePhase.EXECUTE), + contributions.keys, + "Kotlin spark should provide guidance during PLAN and EXECUTE phases", + ) + + val plan = assertNotNull(contributions[CognitivePhase.PLAN]) + assertTrue(plan.contains(".kt"), "PLAN guidance should mention the .kt file extension") + + val execute = assertNotNull(contributions[CognitivePhase.EXECUTE]) + assertTrue( + execute.contains("expect") && execute.contains("actual"), + "EXECUTE guidance should call out expect/actual mismatches as a critical failure mode", + ) + } +} diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/execution/AutonomousWorkLoopTest.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/execution/AutonomousWorkLoopTest.kt index 72d25c02..135383da 100644 --- a/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/execution/AutonomousWorkLoopTest.kt +++ b/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/execution/AutonomousWorkLoopTest.kt @@ -101,7 +101,7 @@ class AutonomousWorkLoopTest { @Test fun loop_canBeStarted() = runTest { - // Since creating a full CodeAgent is complex, we verify the interface + // Since creating a full spark-based code agent + workflow is complex, we verify the interface // The loop should have start/stop methods and isRunning state // This is verified via compilation and type checking assertTrue(true) diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/implementations/CodeWriterAgentIntegrationTest.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/implementations/CodeWriterAgentIntegrationTest.kt deleted file mode 100644 index be8e8455..00000000 --- a/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/implementations/CodeWriterAgentIntegrationTest.kt +++ /dev/null @@ -1,676 +0,0 @@ -package link.socket.ampere.agents.implementations - -import kotlin.test.Ignore -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import kotlinx.datetime.Clock -import link.socket.ampere.agents.config.AgentActionAutonomy -import link.socket.ampere.agents.definition.CodeAgent -import link.socket.ampere.agents.definition.code.CodeState -import link.socket.ampere.agents.domain.error.ExecutionError -import link.socket.ampere.agents.domain.outcome.ExecutionOutcome -import link.socket.ampere.agents.domain.outcome.Outcome -import link.socket.ampere.agents.domain.reasoning.AgentReasoning -import link.socket.ampere.agents.domain.reasoning.EvaluationResult -import link.socket.ampere.agents.domain.reasoning.Idea -import link.socket.ampere.agents.domain.reasoning.Perception -import link.socket.ampere.agents.domain.reasoning.Plan -import link.socket.ampere.agents.domain.state.AgentState -import link.socket.ampere.agents.domain.status.TaskStatus -import link.socket.ampere.agents.domain.task.Task -import link.socket.ampere.agents.execution.executor.FunctionExecutor -import link.socket.ampere.agents.execution.executor.InstrumentedExecutor -import link.socket.ampere.agents.execution.request.ExecutionContext -import link.socket.ampere.agents.execution.results.ExecutionResult -import link.socket.ampere.agents.execution.tools.FunctionTool -import link.socket.ampere.agents.execution.tools.Tool -import link.socket.ampere.stubAgentConfiguration - -/** - * Comprehensive integration tests for the CodeWriterAgent's complete cognitive loop. - * - * These tests validate that the agent can autonomously: - * - Perceive its current state and tasks - * - Plan concrete steps to accomplish goals - * - Execute plans using tools through executors - * - Evaluate outcomes and generate learnings - * - Apply learnings to future tasks - * - * This suite verifies the "metabolic loop" of autonomous agency—the continuous cycle - * of perception → planning → execution → evaluation that enables genuine autonomy. - */ -class CodeWriterAgentIntegrationTest { - - /** - * Test 1: Complete cognitive loop for simple task - * - * Validates that the agent can: - * - Receive a simple task - * - Perceive the current state - * - Generate a plan - * - Execute the plan - * - Evaluate the outcome - * - Complete successfully without human intervention - */ - @Ignore - @Test - fun `test complete cognitive loop for simple task`() = runBlocking { - // Create a mock tool that always succeeds - val mockTool = createMockWriteCodeFileTool(alwaysSucceed = true) - val executor = FunctionExecutor.create() - - // Create mock reasoning that returns predetermined responses - val mockReasoning = createMockReasoning() - - val agent = CodeAgent( - initialState = CodeState.blank, - agentConfiguration = stubAgentConfiguration(), - toolWriteCodeFile = mockTool, - coroutineScope = this, - executor = executor, - reasoningOverride = mockReasoning, - ) - - // Create a simple code change task - val task = Task.CodeChange( - id = "simple-task-1", - status = TaskStatus.Pending, - description = "Create a simple data class User with fields name and email", - ) - - // Run through the complete cognitive cycle - // 1. Perceive current state - val perception = agent.perceiveState(agent.getCurrentState()) - assertNotNull(perception) - assertTrue(perception.ideas.first().name.isNotEmpty(), "Perception should generate insights") - - // 2. Generate a plan - val plan = agent.determinePlanForTask(task, perception.ideas.first(), relevantKnowledge = emptyList()) - assertNotNull(plan) - assertIs(plan) - assertTrue(plan.tasks.isNotEmpty(), "Plan should contain steps") - - // 3. Execute the plan - val outcome = agent.executePlan(plan) - assertNotNull(outcome) - - // For a mock tool that always succeeds, we expect success - assertTrue(outcome is Outcome.Success, "Execution should succeed with mock tool") - - // 4. Evaluate outcomes to generate learnings - val learningIdea = agent.evaluateNextIdeaFromOutcomes(outcome) - assertNotNull(learningIdea) - assertTrue(learningIdea.name.isNotEmpty(), "Should generate learning insights") - - // Verify the complete cognitive cycle executed successfully - // (Note: Knowledge extraction is tested separately in other tests) - } - - /** - * Test 2: Cognitive state transitions - * - * Validates that: - * - Agent state transitions correctly through the cognitive cycle - * - Current memory is properly updated at each stage - * - Past memory accumulates correctly - * - * Note: This integration test requires a configured LLM (Anthropic API key in local.properties). - * To run this test, configure your API credentials and remove the @Ignore annotation. - */ - @Ignore - @Test - fun `test cognitive state transitions`() = runBlocking { - val mockTool = createMockWriteCodeFileTool(alwaysSucceed = true) - val mockReasoning = createMockReasoning() - - val agent = CodeAgent( - initialState = CodeState.blank, - agentConfiguration = stubAgentConfiguration(), - toolWriteCodeFile = mockTool, - coroutineScope = this, - executor = FunctionExecutor.create(), - reasoningOverride = mockReasoning, - ) - - val task = Task.CodeChange( - id = "state-transition-task", - status = TaskStatus.Pending, - description = "Test state transitions", - ) - - // Initial state - should be blank - val initialState = agent.getCurrentState() - val initialMemory = initialState.getCurrentMemory() - assertTrue(initialMemory.task is Task.Blank, "Initial task should be blank") - assertTrue(initialMemory.plan is Plan.Blank, "Initial plan should be blank") - assertTrue(initialMemory.outcome is Outcome.Blank, "Initial outcome should be blank") - - // Perceive state - val idea = agent.perceiveState(agent.getCurrentState()) - val afterPerception = agent.getCurrentState() - assertEquals(idea.id, afterPerception.getCurrentMemory().idea.id, "Idea should be stored in current memory") - - // Generate plan - val plan = agent.determinePlanForTask(task, idea.ideas.first(), relevantKnowledge = emptyList()) - val afterPlanning = agent.getCurrentState() - assertEquals(plan.id, afterPlanning.getCurrentMemory().plan.id, "Plan should be stored in current memory") - - // Execute task - val outcome = agent.runTask(task) - val afterExecution = agent.getCurrentState() - assertEquals( - outcome.id, - afterExecution.getCurrentMemory().outcome.id, - "Outcome should be stored in current memory", - ) - - // Verify past memory has accumulated - val finalState = agent.getCurrentState() - val pastMemory = finalState.getPastMemory() - assertTrue(pastMemory.ideas.isNotEmpty(), "Past memory should contain ideas") - assertTrue(pastMemory.plans.isNotEmpty(), "Past memory should contain plans") - assertTrue(pastMemory.outcomes.isNotEmpty(), "Past memory should contain outcomes") - } - - /** - * Test 3: Learnings persist across tasks - * - * Validates that: - * - Knowledge extracted from one task is available to future tasks - * - The agent can recall relevant learnings when planning new tasks - * - The learning loop actually closes (outcomes → knowledge → planning) - */ - @Ignore - @Test - fun `test learnings persist across tasks`() = runBlocking { - val mockTool = createMockWriteCodeFileTool(alwaysSucceed = true) - val agent = CodeAgent( - initialState = CodeState.blank, - agentConfiguration = stubAgentConfiguration(), - toolWriteCodeFile = mockTool, - coroutineScope = this, - executor = FunctionExecutor.create(), - ) - - // First task - execute and generate learnings - val firstTask = Task.CodeChange( - id = "learning-task-1", - status = TaskStatus.Pending, - description = "Implement user authentication", - ) - - val firstPlan = agent.determinePlanForTask(firstTask, relevantKnowledge = emptyList()) - val firstOutcome = agent.executePlan(firstPlan) - - // Extract knowledge from first task - val firstKnowledge = agent.extractKnowledgeFromOutcome(firstOutcome, firstTask, firstPlan) - assertNotNull(firstKnowledge) - assertTrue(firstKnowledge.approach.isNotEmpty()) - assertTrue(firstKnowledge.learnings.isNotEmpty()) - - // Store knowledge in agent state - agent.getCurrentState().addToPastKnowledge( - rememberedKnowledgeFromOutcomes = listOf(firstKnowledge), - ) - - // Second task - should benefit from first task's learnings - val secondTask = Task.CodeChange( - id = "learning-task-2", - status = TaskStatus.Pending, - description = "Implement user authorization", - ) - - // Verify learnings are available in state - val stateBeforeSecondTask = agent.getCurrentState() - val knowledgeFromPastOutcomes = stateBeforeSecondTask.getPastMemory().knowledgeFromOutcomes - assertTrue( - knowledgeFromPastOutcomes.isNotEmpty(), - "Past knowledge should be available for second task", - ) - assertTrue( - knowledgeFromPastOutcomes.any { it.approach == firstKnowledge.approach }, - "First task's knowledge should be retrievable", - ) - - // Execute second task - val secondPlan = agent.determinePlanForTask(secondTask, relevantKnowledge = emptyList()) - assertNotNull(secondPlan) - assertTrue(secondPlan.tasks.isNotEmpty()) - } - - /** - * Test 4: Failure recovery - * - * Validates that: - * - When execution fails, the agent handles it gracefully - * - Failure outcomes are properly recorded - * - The agent can continue operating after failures - * - Learnings are extracted from failures - */ - @Ignore - @Test - fun `test failure recovery`() = runBlocking { - // Create a mock tool that always fails - val mockTool = createMockWriteCodeFileTool(alwaysSucceed = false) - val agent = CodeAgent( - initialState = CodeState.blank, - agentConfiguration = stubAgentConfiguration(), - toolWriteCodeFile = mockTool, - coroutineScope = this, - executor = FunctionExecutor.create(), - ) - - val task = Task.CodeChange( - id = "failure-task", - status = TaskStatus.Pending, - description = "Task that will fail", - ) - - // Execute task (should fail) - val outcome = agent.runTask(task) - - // Verify it's a failure outcome - assertIs(outcome, "Outcome should be a failure") - - // Verify agent state reflects the failure - val state = agent.getCurrentState() - val currentOutcome = state.getCurrentMemory().outcome - assertIs(currentOutcome, "Current memory should have failure outcome") - - // Extract knowledge from failure - val failureKnowledge = agent.extractKnowledgeFromOutcome( - outcome, - task, - Plan.ForTask(task = task), - ) - assertNotNull(failureKnowledge) - assertTrue( - failureKnowledge.learnings.contains("fail", ignoreCase = true), - "Learnings should mention failure", - ) - - // Verify agent can continue - create another task - val recoveryTask = Task.CodeChange( - id = "recovery-task", - status = TaskStatus.Pending, - description = "Task after failure", - ) - - // Agent should still be able to plan and execute - val recoveryPlan = agent.determinePlanForTask(recoveryTask, relevantKnowledge = emptyList()) - assertNotNull(recoveryPlan) - assertTrue(recoveryPlan.tasks.isNotEmpty(), "Agent should continue functioning after failure") - } - - /** - * Test 5: Multiple tasks processed sequentially - * - * Validates that: - * - Agent can handle multiple tasks in sequence - * - Each task gets its own perception, plan, execution, evaluation cycle - * - State doesn't corrupt across tasks - * - Memory accumulates correctly - * - * Note: This integration test requires a configured LLM (Anthropic API key in local.properties). - * To run this test, configure your API credentials and remove the @Ignore annotation. - */ - @Ignore - @Test - fun `test multiple tasks processed sequentially`() = runBlocking { - val mockTool = createMockWriteCodeFileTool(alwaysSucceed = true) - val mockReasoning = createMockReasoning() - - val agent = CodeAgent( - initialState = CodeState.blank, - agentConfiguration = stubAgentConfiguration(), - toolWriteCodeFile = mockTool, - coroutineScope = this, - executor = FunctionExecutor.create(), - reasoningOverride = mockReasoning, - ) - - val tasks = listOf( - Task.CodeChange( - id = "seq-task-1", - status = TaskStatus.Pending, - description = "Create User data class", - ), - Task.CodeChange( - id = "seq-task-2", - status = TaskStatus.Pending, - description = "Create UserRepository interface", - ), - Task.CodeChange( - id = "seq-task-3", - status = TaskStatus.Pending, - description = "Create UserService class", - ), - ) - - val outcomes = mutableListOf() - - // Process each task sequentially - for (task in tasks) { - // Full cognitive cycle for each task - val idea = agent.perceiveState(agent.getCurrentState()) - val plan = agent.determinePlanForTask(task, idea.ideas.first(), relevantKnowledge = emptyList()) - val outcome = agent.executePlan(plan) - val learningIdea = agent.evaluateNextIdeaFromOutcomes(outcome) - - outcomes.add(outcome) - - // Extract and store knowledge - val knowledge = agent.extractKnowledgeFromOutcome(outcome, task, plan) - agent.getCurrentState().addToPastKnowledge( - rememberedKnowledgeFromOutcomes = listOf(knowledge), - ) - } - - // Verify all tasks completed - assertEquals(3, outcomes.size, "Should have processed all 3 tasks") - - // Verify all succeeded (with mock tool) - assertTrue(outcomes.all { it is Outcome.Success }, "All tasks should succeed with mock tool") - - // Verify memory accumulated correctly - val finalState = agent.getCurrentState() - val pastMemory = finalState.getPastMemory() - assertTrue( - pastMemory.knowledgeFromOutcomes.size >= 3, - "Should have accumulated knowledge from all tasks", - ) - assertTrue( - pastMemory.tasks.size >= 3, - "Should have accumulated task history", - ) - assertTrue( - pastMemory.outcomes.size >= 3, - "Should have accumulated outcome history", - ) - } - - /** - * Test 6: Executor abstraction is used correctly - * - * Validates that: - * - Agent invokes tools through executors, not directly - * - Executors are properly passed to cognitive functions - * - The architectural pattern (agents → executors → tools) is respected - * - * Note: This integration test requires a configured LLM (Anthropic API key in local.properties). - * To run this test, configure your API credentials and remove the @Ignore annotation. - */ - @Ignore - @Test - fun `test executor abstraction is used correctly`() = runBlocking { - // Create an instrumented executor to track calls - val instrumentedExecutor = InstrumentedExecutor() - - val mockTool = createMockWriteCodeFileTool(alwaysSucceed = true) - val agent = CodeAgent( - initialState = CodeState.blank, - agentConfiguration = stubAgentConfiguration(), - toolWriteCodeFile = mockTool, - coroutineScope = this, - executor = instrumentedExecutor, - ) - - val task = Task.CodeChange( - id = "executor-test-task", - status = TaskStatus.Pending, - description = "Test executor usage", - ) - - // Execute task - agent.runTask(task) - - // Verify executor was called (not tool directly) - assertTrue( - instrumentedExecutor.executorWasCalled, - "Agent should invoke tools through executor, not directly", - ) - } - - /** - * Test 7: Vague requirement to working code - * - * This is the ultimate validation of autonomous agency: given a vague, - * natural language requirement, the agent should autonomously produce - * a reasonable working implementation. - * - * This test validates: - * - Natural language understanding - * - Code generation from high-level intent - * - Autonomous transformation: idea → plan → code - */ - @Ignore - @Test - fun `test vague requirement to working code`() = runBlocking { - val mockTool = createMockWriteCodeFileTool(alwaysSucceed = true) - val agent = CodeAgent( - initialState = CodeState.blank, - agentConfiguration = stubAgentConfiguration(), - toolWriteCodeFile = mockTool, - coroutineScope = this, - executor = FunctionExecutor.create(), - ) - - // Vague, natural language requirement (like a PM might give) - val vagueRequirement = "I need a way to store user information" - - val task = Task.CodeChange( - id = "vague-req-task", - status = TaskStatus.Pending, - description = vagueRequirement, - ) - - // Agent should autonomously transform this vague requirement into concrete code - - // 1. Perceive and understand the requirement - val idea = agent.perceiveState(agent.getCurrentState()) - assertNotNull(idea) - assertTrue(idea.ideas.first().name.isNotEmpty(), "Agent should generate insights from vague requirement") - - // 2. Plan concrete steps - val plan = agent.determinePlanForTask(task, idea.ideas.first(), relevantKnowledge = emptyList()) - assertNotNull(plan) - assertIs(plan) - assertTrue(plan.tasks.isNotEmpty(), "Agent should break down vague requirement into steps") - - // 3. Execute the plan - val outcome = agent.executePlan(plan) - assertNotNull(outcome) - - // 4. Verify execution completed (success or failure, but not blank) - assertFalse(outcome is Outcome.Blank, "Agent should produce a real outcome") - - // 5. Evaluate and learn - val learningIdea = agent.evaluateNextIdeaFromOutcomes(outcome) - assertNotNull(learningIdea) - - // The test passes if the agent: - // - Understood the vague requirement (generated insights) - // - Created a concrete plan (has actionable steps) - // - Attempted execution (produced an outcome) - // - Learned from the experience (generated learnings) - // This demonstrates autonomous agency: vague → concrete → action → learning - - assertTrue( - plan.tasks.isNotEmpty() && outcome !is Outcome.Blank, - "Agent should autonomously transform vague requirement into concrete action", - ) - } - - /** - * Test 8: Runtime loop integration - * - * Validates that: - * - The agent's runtime loop (from AutonomousAgent) works correctly - * - Agent can be initialized, run, paused, and shutdown - * - The continuous cognitive loop operates as expected - */ - @Ignore - @Test - fun `test runtime loop integration`() = runBlocking { - val mockTool = createMockWriteCodeFileTool(alwaysSucceed = true) - val agent = CodeAgent( - initialState = CodeState.blank, - agentConfiguration = stubAgentConfiguration(), - toolWriteCodeFile = mockTool, - coroutineScope = this, - executor = FunctionExecutor.create(), - ) - - // Set initial task - val task = Task.CodeChange( - id = "runtime-loop-task", - status = TaskStatus.Pending, - description = "Test runtime loop", - ) - agent.getCurrentState().setNewTask(task) - - // Initialize agent (starts runtime loop) - agent.initialize(this) - - // Give the runtime loop time to execute - delay(2.seconds) - - // Pause agent - agent.pauseAgent() - - // Verify the agent executed the cognitive loop - val state = agent.getCurrentState() - val pastMemory = state.getPastMemory() - - // The runtime loop should have: - // - Generated ideas through perception - // - Created plans - // - Executed tasks - // - Evaluated outcomes - assertTrue( - pastMemory.ideas.isNotEmpty() || pastMemory.plans.isNotEmpty(), - "Runtime loop should have executed cognitive functions", - ) - - // Cleanup - agent.shutdownAgent() - } - - // ==================== Helper Functions ==================== - - /** - * Creates a mock AgentReasoning that returns predetermined responses - * without requiring a real LLM connection. - * - * This enables testing the cognitive loop without LLM credentials. - */ - private fun createMockReasoning(): AgentReasoning { - return AgentReasoning.createForTesting("test-executor") { - onPerception { perception: Perception -> - Idea( - name = "Mock perception analysis", - description = "Agent should execute the pending task (confidence: high)", - ) - } - - onPlanning { task: Task, ideas: List -> - Plan.ForTask( - task = task, - tasks = listOf(task), - estimatedComplexity = 1, - ) - } - - onToolExecution { tool, request -> - val now = Clock.System.now() - ExecutionOutcome.CodeChanged.Success( - executorId = "mock-executor", - ticketId = request.context.ticket.id, - taskId = request.context.task.id, - executionStartTimestamp = now, - executionEndTimestamp = now + 100.milliseconds, - changedFiles = listOf("MockFile.kt"), - validation = ExecutionResult( - codeChanges = null, - compilation = null, - linting = null, - tests = null, - ), - ) - } - - onOutcomeEvaluation { outcomes: List -> - EvaluationResult( - summaryIdea = Idea( - name = "Mock outcome evaluation", - description = "Task completed successfully. Learning: Mock tools work as expected.", - ), - knowledge = emptyList(), - ) - } - - onLLMCall { prompt: String -> - "Mock LLM response for: $prompt" - } - } - } - - /** - * Creates a mock write_code_file tool for testing. - * - * @param alwaysSucceed If true, tool always returns success; if false, always returns failure - */ - private fun createMockWriteCodeFileTool(alwaysSucceed: Boolean): Tool { - return FunctionTool( - id = "write_code_file", - name = "Write Code File", - description = "Mock tool for writing code to files", - requiredAgentAutonomy = AgentActionAutonomy.FULLY_AUTONOMOUS, - executionFunction = { request -> - val now = Clock.System.now() - - if (alwaysSucceed) { - // Simulate successful file write - val changedFiles = request.context.instructionsPerFilePath.map { it.first } - - ExecutionOutcome.CodeChanged.Success( - executorId = "mock-executor", - ticketId = request.context.ticket.id, - taskId = request.context.task.id, - executionStartTimestamp = now, - executionEndTimestamp = now + 100.milliseconds, - changedFiles = changedFiles, - validation = ExecutionResult( - codeChanges = null, - compilation = null, - linting = null, - tests = null, - ), - ) - } else { - // Simulate failure - ExecutionOutcome.CodeChanged.Failure( - executorId = "mock-executor", - ticketId = request.context.ticket.id, - taskId = request.context.task.id, - executionStartTimestamp = now, - executionEndTimestamp = now + 50.milliseconds, - error = ExecutionError( - type = ExecutionError.Type.TOOL_UNAVAILABLE, - message = "Mock tool failed intentionally", - ), - partiallyChangedFiles = null, - ) - } - }, - ) - } -} diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/implementations/CodeWriterAgentTest.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/implementations/CodeWriterAgentTest.kt deleted file mode 100644 index 89ea06a0..00000000 --- a/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/implementations/CodeWriterAgentTest.kt +++ /dev/null @@ -1,5 +0,0 @@ -package link.socket.ampere.agents.implementations - -expect class CodeWriterAgentTest { - // TODO: Implement common tests for CodeWriterAgent -} diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/implementations/code/RunLLMToExecuteToolTest.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/implementations/code/RunLLMToExecuteToolTest.kt deleted file mode 100644 index c82ba173..00000000 --- a/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/implementations/code/RunLLMToExecuteToolTest.kt +++ /dev/null @@ -1,223 +0,0 @@ -package link.socket.ampere.agents.implementations.code - -import kotlin.test.Test -import kotlin.test.assertIs -import kotlin.test.assertTrue -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.datetime.Clock -import link.socket.ampere.agents.config.AgentActionAutonomy -import link.socket.ampere.agents.config.AgentConfiguration -import link.socket.ampere.agents.definition.CodeAgent -import link.socket.ampere.agents.definition.code.CodeState -import link.socket.ampere.agents.domain.outcome.ExecutionOutcome -import link.socket.ampere.agents.domain.status.TaskStatus -import link.socket.ampere.agents.domain.status.TicketStatus -import link.socket.ampere.agents.domain.task.Task -import link.socket.ampere.agents.environment.workspace.ExecutionWorkspace -import link.socket.ampere.agents.events.tickets.Ticket -import link.socket.ampere.agents.events.tickets.TicketPriority -import link.socket.ampere.agents.events.tickets.TicketType -import link.socket.ampere.agents.execution.executor.FunctionExecutor -import link.socket.ampere.agents.execution.request.ExecutionConstraints -import link.socket.ampere.agents.execution.request.ExecutionContext -import link.socket.ampere.agents.execution.request.ExecutionRequest -import link.socket.ampere.agents.execution.tools.FunctionTool -import link.socket.ampere.agents.execution.tools.Tool -import link.socket.ampere.domain.agent.bundled.WriteCodeAgent -import link.socket.ampere.domain.ai.configuration.AIConfiguration_Default -import link.socket.ampere.domain.ai.model.AIModel_Claude -import link.socket.ampere.domain.ai.provider.AIProvider_Anthropic - -/** - * Tests for the runLLMToExecuteTool function in CodeWriterAgent. - * - * These tests validate that the function: - * 1. Generates appropriate parameters for tools using LLM - * 2. Executes tools through the executor pattern - * 3. Handles errors gracefully - * 4. Works with different tool types - * - * Note: These are primarily structure/API tests since we can't easily mock - * the LLM service in the current architecture. Integration tests would - * require actual LLM calls. - */ -class RunLLMToExecuteToolTest { - - private fun createTestTicket(id: String = "test-ticket"): Ticket { - val now = Clock.System.now() - return Ticket( - id = id, - title = "Test ticket", - description = "Test ticket description", - type = TicketType.TASK, - priority = TicketPriority.MEDIUM, - status = TicketStatus.InProgress, - assignedAgentId = "test-agent", - createdByAgentId = "test-agent", - createdAt = now, - updatedAt = now, - dueDate = null, - ) - } - - private fun createTestTask(id: String = "test-task"): Task.CodeChange { - return Task.CodeChange( - id = id, - status = TaskStatus.Pending, - description = "Create a simple data class for User", - ) - } - - private fun createTestAgentConfiguration(): AgentConfiguration { - return AgentConfiguration( - agentDefinition = WriteCodeAgent, - aiConfiguration = AIConfiguration_Default( - provider = AIProvider_Anthropic, - model = AIModel_Claude.Sonnet_4, - ), - ) - } - - @Test - fun `runLLMToExecuteTool handles empty intent gracefully`() = runBlocking { - // Use real FunctionExecutor instead of mock - val functionExecutor = FunctionExecutor.create() - - val writeCodeTool = FunctionTool( - id = "write_code_file", - name = "Write Code File", - description = "Writes code to a file", - requiredAgentAutonomy = AgentActionAutonomy.FULLY_AUTONOMOUS, - executionFunction = { request -> - throw IllegalStateException("Tool should be executed through executor") - }, - ) - - val agent = CodeAgent( - initialState = CodeState.blank, - agentConfiguration = createTestAgentConfiguration(), - toolWriteCodeFile = writeCodeTool, - coroutineScope = CoroutineScope(Dispatchers.Default), - executor = functionExecutor, - ) - - // Create request with empty instructions (empty intent) - val ticket = createTestTicket() - val task = createTestTask() - val request = ExecutionRequest( - context = ExecutionContext.Code.WriteCode( - executorId = functionExecutor.id, - ticket = ticket, - task = task, - instructions = "", // Empty instructions - workspace = ExecutionWorkspace(baseDirectory = "."), - instructionsPerFilePath = emptyList(), - ), - constraints = ExecutionConstraints( - requireTests = false, - requireLinting = false, - ), - ) - - val outcome = agent.runLLMToExecuteTool(writeCodeTool, request) - - // Should return a failure outcome due to empty intent - assertIs(outcome) - assertTrue( - (outcome as ExecutionOutcome.NoChanges.Failure).message.contains("no intent"), - "Error message should mention missing intent", - ) - } - - @Test - fun `runLLMToExecuteTool returns failure for MCP tools`() = runBlocking { - // Use real FunctionExecutor instead of mock - val functionExecutor = FunctionExecutor.create() - - val mcpTool = link.socket.ampere.agents.execution.tools.McpTool( - id = "mcp_test_tool", - name = "MCP Test Tool", - description = "A test MCP tool", - requiredAgentAutonomy = AgentActionAutonomy.FULLY_AUTONOMOUS, - serverId = "test-server", - remoteToolName = "test-tool", - inputSchema = null, - ) - - val writeCodeTool = FunctionTool( - id = "write_code_file", - name = "Write Code File", - description = "Writes code to a file", - requiredAgentAutonomy = AgentActionAutonomy.FULLY_AUTONOMOUS, - executionFunction = { request -> - throw IllegalStateException("Tool should be executed through executor") - }, - ) - - val agent = CodeAgent( - initialState = CodeState.blank, - agentConfiguration = createTestAgentConfiguration(), - toolWriteCodeFile = writeCodeTool, - coroutineScope = CoroutineScope(Dispatchers.Default), - executor = functionExecutor, - ) - - val ticket = createTestTicket() - val task = createTestTask() - val request = ExecutionRequest( - context = ExecutionContext.NoChanges( - executorId = functionExecutor.id, - ticket = ticket, - task = task, - instructions = "Test MCP tool execution", - ), - constraints = ExecutionConstraints( - requireTests = false, - requireLinting = false, - ), - ) - - val outcome = agent.runLLMToExecuteTool(mcpTool, request) - - // Should return failure for MCP tools (not yet supported) - assertIs(outcome) - assertTrue( - (outcome as ExecutionOutcome.NoChanges.Failure).message.contains("MCP tool"), - "Error message should mention MCP tools not being supported", - ) - } - - @Test - fun `runLLMToExecuteTool function signature exists and is callable`() { - // This test verifies that the function exists with the correct signature - // and can be called. Full integration testing would require mocking the LLM service. - - // Use real FunctionExecutor instead of mock - val functionExecutor = FunctionExecutor.create() - - val writeCodeTool = FunctionTool( - id = "write_code_file", - name = "Write Code File", - description = "Writes code to a file", - requiredAgentAutonomy = AgentActionAutonomy.FULLY_AUTONOMOUS, - executionFunction = { request -> - throw IllegalStateException("Tool should be executed through executor") - }, - ) - - val agent = CodeAgent( - initialState = CodeState.blank, - agentConfiguration = createTestAgentConfiguration(), - toolWriteCodeFile = writeCodeTool, - coroutineScope = CoroutineScope(Dispatchers.Default), - executor = functionExecutor, - ) - - // Verify the function exists and has the correct signature - val function: (Tool<*>, ExecutionRequest<*>) -> ExecutionOutcome = agent.runLLMToExecuteTool - - assertTrue(function.toString().isNotBlank(), "runLLMToExecuteTool should be callable") - } -} diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/definition/CodeAgentIntegrationTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/definition/CodeAgentIntegrationTest.kt deleted file mode 100644 index ff9ecf20..00000000 --- a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/definition/CodeAgentIntegrationTest.kt +++ /dev/null @@ -1,645 +0,0 @@ -package link.socket.ampere.agents.definition - -import kotlin.test.Ignore -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.runTest -import kotlinx.datetime.Clock -import link.socket.ampere.agents.config.AgentActionAutonomy -import link.socket.ampere.agents.config.AgentConfiguration -import link.socket.ampere.agents.definition.code.IssueWorkflowStatus -import link.socket.ampere.agents.domain.outcome.ExecutionOutcome -import link.socket.ampere.agents.execution.request.ExecutionContext -import link.socket.ampere.agents.execution.tools.FunctionTool -import link.socket.ampere.agents.execution.tools.issue.CreatedIssue -import link.socket.ampere.agents.execution.tools.issue.IssueCreateRequest -import link.socket.ampere.domain.agent.bundled.WriteCodeAgent -import link.socket.ampere.domain.ai.configuration.AIConfiguration -import link.socket.ampere.domain.ai.model.AIModel -import link.socket.ampere.domain.ai.model.AIModel_OpenAI -import link.socket.ampere.domain.ai.provider.AIProvider -import link.socket.ampere.integrations.issues.ExistingIssue -import link.socket.ampere.integrations.issues.IssueQuery -import link.socket.ampere.integrations.issues.IssueState -import link.socket.ampere.integrations.issues.IssueTrackerProvider -import link.socket.ampere.integrations.issues.IssueUpdate - -/** - * Integration tests for CodeAgent's full issue-to-PR workflow. - * - * These tests validate the complete autonomous pipeline: - * 1. Issue Discovery - Agent finds assigned/available issues - * 2. Planning - Agent creates implementation plan with Git workflow - * 3. Execution - Agent writes code, creates branch, commits, pushes - * 4. PR Creation - Agent creates pull request with proper formatting - * 5. Status Updates - Issue labels track progress through workflow - * - * ## SUCCESS CRITERIA - * - * ### Phase 1: Issue Management (COMPLETED) - * ✅ Agent can discover unassigned issues with 'code' label - * ✅ Agent can discover assigned issues - * ✅ Agent can update issue status to IN_PROGRESS - * ✅ Agent can update issue status to IN_REVIEW after PR creation - * ✅ Agent can mark issues as BLOCKED on failure - * ✅ IssueWorkflowStatus correctly parses from labels - * ✅ IssueWorkflowStatus prioritizes later statuses when multiple present - * - * ### Phase 2: Git Operations (PENDING) - * ⏳ Agent creates feature branches with proper naming (issue-N-description) - * ⏳ Agent commits code with conventional commit messages - * ⏳ Agent pushes branches to remote repository - * ⏳ Agent creates PRs with formatted body (Summary, Changes, Testing, Checklist) - * ⏳ Agent assigns reviewers based on file analysis - * ⏳ PRs auto-link to issues with "Closes #N" - * - * ### Phase 3: End-to-End Workflow (PENDING) - * ⏳ Agent completes full issue-to-PR pipeline autonomously - * ⏳ Issue status transitions through all stages (CLAIMED → IN_PROGRESS → IN_REVIEW) - * ⏳ Events published for each workflow stage - * ⏳ Agent handles review feedback and updates PRs - * - * ### Phase 4: Error Handling (PENDING) - * ⏳ Agent marks issues as BLOCKED on unrecoverable errors - * ⏳ Agent retries transient failures - * ⏳ Agent escalates to humans when stuck - * - * ## IMPLEMENTATION STATUS - * - * **Current State:** Phase 1 complete, Phase 2-4 require Git tool implementation - * - * **Blocked By:** - * - Git tools execute placeholder operations instead of real git/gh commands - * - Full workflow orchestration needs event-driven coordination - * - Review feedback handling requires PR comment monitoring - * - * **Next Steps:** - * 1. Implement actual Git operations in ToolCreateBranch, ToolCommit, etc. - * 2. Enable placeholder integration tests by removing @Ignore annotations - * 3. Add event verification to validate workflow events are published - * 4. Implement review feedback processing workflow - * - * NOTE: Many tests are currently disabled (@Ignore) because they require: - * - Full Git tool implementation (currently placeholders) - * - Issue tracker provider with comment support - * - Complete workflow orchestration - * - * Enable these tests incrementally as those components are implemented. - */ -class CodeAgentIntegrationTest { - - /** - * Fake AI configuration for testing that doesn't make real API calls. - */ - private class FakeAIConfiguration : AIConfiguration { - override val provider: AIProvider<*, *> - get() = throw NotImplementedError("Provider not needed for these tests") - override val model: AIModel - get() = AIModel_OpenAI.GPT_4_1 - - override fun getAvailableModels(): List, AIModel>> = emptyList() - } - - /** - * Mock issue tracker for testing issue discovery and updates. - */ - private class TestIssueTrackerProvider : IssueTrackerProvider { - override val providerId = "test-github" - override val displayName = "Test GitHub" - - val issues = mutableMapOf() - var nextIssueNumber = 1 - - // Race condition simulation - var simulateRaceCondition = false - var raceConditionIssueNumber: Int? = null - - override suspend fun validateConnection(): Result = Result.success(Unit) - - override suspend fun createIssue( - repository: String, - request: IssueCreateRequest, - resolvedDependencies: Map, - ): Result { - val number = nextIssueNumber++ - val issue = ExistingIssue( - number = number, - title = request.title, - body = request.body, - state = IssueState.Open, - labels = request.labels, - url = "https://github.com/test/repo/issues/$number", - ) - issues[number] = issue - return Result.success( - CreatedIssue( - localId = request.localId, - issueNumber = number, - url = issue.url, - ), - ) - } - - override suspend fun setParentRelationship( - repository: String, - childIssueNumber: Int, - parentIssueNumber: Int, - ): Result = Result.success(Unit) - - override suspend fun queryIssues( - repository: String, - query: IssueQuery, - ): Result> { - // Simulate race condition: change issue labels to IN_PROGRESS - // This simulates another agent claiming and starting work - if (simulateRaceCondition && raceConditionIssueNumber != null) { - issues[raceConditionIssueNumber]?.let { issue -> - // Only modify if it has "assigned" label (was just claimed) - if (issue.labels.contains("assigned")) { - issues[raceConditionIssueNumber!!] = issue.copy( - labels = issue.labels - .filter { it != "assigned" } - .plus("in-progress"), - ) - } - } - } - - var filtered = issues.values.toList() - - query.state?.let { state -> - if (state != IssueState.All) { - filtered = filtered.filter { it.state == state } - } - } - - query.assignee?.let { assignee -> - // For this mock, we'll just return all issues when assignee is set - // In a real implementation, this would filter by assignee - } - - if (query.labels.isNotEmpty()) { - filtered = filtered.filter { issue -> - query.labels.all { label -> issue.labels.contains(label) } - } - } - - return Result.success(filtered.take(query.limit)) - } - - override suspend fun updateIssue( - repository: String, - issueNumber: Int, - update: IssueUpdate, - ): Result { - val current = issues[issueNumber] - ?: return Result.failure(IllegalArgumentException("Issue #$issueNumber not found")) - - val updated = current.copy( - title = update.title ?: current.title, - body = update.body ?: current.body, - state = update.state ?: current.state, - labels = update.labels ?: current.labels, - ) - - issues[issueNumber] = updated - return Result.success(updated) - } - - fun createTestIssue( - title: String, - body: String = "", - labels: List = emptyList(), - assignee: String? = null, - ): ExistingIssue { - val number = nextIssueNumber++ - val issue = ExistingIssue( - number = number, - title = title, - body = body, - state = IssueState.Open, - labels = labels, - url = "https://github.com/test/repo/issues/$number", - ) - issues[number] = issue - return issue - } - } - - private fun createTestAgent( - issueProvider: TestIssueTrackerProvider, - ): CodeAgent { - val mockWriteTool = FunctionTool( - id = "write_code_file", - name = "write_code_file", - description = "Write code to a file", - requiredAgentAutonomy = AgentActionAutonomy.ACT_WITH_NOTIFICATION, - executionFunction = { _ -> - ExecutionOutcome.CodeChanged.Success( - executorId = "test", - ticketId = "test-ticket", - taskId = "test-task", - changedFiles = listOf("src/StringUtils.kt"), - executionStartTimestamp = Clock.System.now(), - executionEndTimestamp = Clock.System.now(), - validation = link.socket.ampere.agents.execution.results.ExecutionResult( - codeChanges = null, - compilation = null, - linting = null, - tests = null, - ), - ) - }, - ) - - return CodeAgent( - agentConfiguration = AgentConfiguration( - agentDefinition = WriteCodeAgent, - aiConfiguration = FakeAIConfiguration(), - ), - toolWriteCodeFile = mockWriteTool, - coroutineScope = CoroutineScope(Dispatchers.Default), - issueTrackerProvider = issueProvider, - repository = "test/repo", - ) - } - - // ======================================================================== - // Issue Discovery Tests - // ======================================================================== - - @Test - fun `agent discovers unassigned issues with code label`() = runTest { - val issueProvider = TestIssueTrackerProvider() - val agent = createTestAgent(issueProvider) - - // Create unassigned issue with 'code' label - issueProvider.createTestIssue( - title = "Add greeting function", - body = "Create a function that greets users", - labels = listOf("task", "code"), - ) - - // Query available issues - val availableIssues = agent.queryAvailableIssues() - - // Verify issue is discovered - assertEquals(1, availableIssues.size) - assertTrue(availableIssues[0].title.contains("greeting")) - } - - @Test - fun `agent discovers assigned issues`() = runTest { - val issueProvider = TestIssueTrackerProvider() - val agent = createTestAgent(issueProvider) - - // Create assigned issue (mock assigns by adding to query results) - issueProvider.createTestIssue( - title = "Implement helper utility", - body = "Add utility functions", - labels = listOf("code"), - assignee = "CodeWriterAgent", - ) - - // Query assigned issues - val assignedIssues = agent.queryAssignedIssues() - - // Verify issue is discovered - assertEquals(1, assignedIssues.size) - assertTrue(assignedIssues[0].title.contains("helper")) - } - - // ======================================================================== - // Status Update Tests - // ======================================================================== - - @Test - fun `agent updates issue status to IN_PROGRESS`() = runTest { - val issueProvider = TestIssueTrackerProvider() - val agent = createTestAgent(issueProvider) - - // Create test issue - val issue = issueProvider.createTestIssue( - title = "Add feature", - labels = listOf("code", "assigned"), - ) - - // Update status - val result = agent.updateIssueStatus( - issueNumber = issue.number, - status = IssueWorkflowStatus.IN_PROGRESS, - comment = "Starting work", - ) - - // Verify success - assertTrue(result.isSuccess) - - // Verify labels updated - val updated = result.getOrNull() - assertNotNull(updated) - assertTrue(updated.labels.contains("in-progress")) - assertFalse(updated.labels.contains("assigned")) - } - - @Test - fun `agent updates issue status to IN_REVIEW after PR creation`() = runTest { - val issueProvider = TestIssueTrackerProvider() - val agent = createTestAgent(issueProvider) - - // Create test issue - val issue = issueProvider.createTestIssue( - title = "Implement feature", - labels = listOf("code", "in-progress"), - ) - - // Update to IN_REVIEW (simulating PR creation) - val result = agent.updateIssueStatus( - issueNumber = issue.number, - status = IssueWorkflowStatus.IN_REVIEW, - comment = "Pull request created", - ) - - // Verify labels - val updated = result.getOrNull() - assertNotNull(updated) - assertTrue(updated.labels.contains("in-review")) - assertFalse(updated.labels.contains("in-progress")) - } - - @Test - fun `agent marks issue as BLOCKED on failure`() = runTest { - val issueProvider = TestIssueTrackerProvider() - val agent = createTestAgent(issueProvider) - - // Create test issue - val issue = issueProvider.createTestIssue( - title = "Complex feature", - labels = listOf("code", "in-progress"), - ) - - // Update to BLOCKED - val result = agent.updateIssueStatus( - issueNumber = issue.number, - status = IssueWorkflowStatus.BLOCKED, - comment = "Implementation hit a blocker", - ) - - // Verify labels - val updated = result.getOrNull() - assertNotNull(updated) - assertTrue(updated.labels.contains("blocked")) - assertFalse(updated.labels.contains("in-progress")) - } - - // ======================================================================== - // Workflow Status Enum Tests - // ======================================================================== - - @Test - fun `IssueWorkflowStatus parses from labels correctly`() { - val labels = listOf("code", "in-review", "backend") - val status = IssueWorkflowStatus.fromLabels(labels) - - assertEquals(IssueWorkflowStatus.IN_REVIEW, status) - } - - @Test - fun `IssueWorkflowStatus prioritizes later statuses`() { - // If multiple status labels exist, use the latest in workflow - val labels = listOf("assigned", "in-progress", "in-review") - val status = IssueWorkflowStatus.fromLabels(labels) - - assertEquals(IssueWorkflowStatus.IN_REVIEW, status) - } - - // ======================================================================== - // Issue Claiming Tests (Optimistic Locking) - // ======================================================================== - - @Test - fun `claimIssue successfully claims unassigned issue`() = runTest { - val issueProvider = TestIssueTrackerProvider() - val agent = createTestAgent(issueProvider) - - // Create an unassigned issue (no workflow labels) - val issue = issueProvider.createTestIssue( - title = "Fix bug in authentication", - body = "The login flow has a race condition", - labels = listOf("bug", "backend"), - ) - - // Claim the issue - val result = agent.claimIssue(issue.number) - - // Verify claim succeeded - assertTrue(result.isSuccess) - - // Verify issue is now marked as CLAIMED - val updatedIssue = issueProvider.issues[issue.number] - assertNotNull(updatedIssue) - assertTrue(updatedIssue.labels.contains("assigned")) - assertFalse(updatedIssue.labels.contains("available")) - assertFalse(updatedIssue.labels.contains("help-wanted")) - } - - @Test - fun `claimIssue fails when issue already claimed`() = runTest { - val issueProvider = TestIssueTrackerProvider() - val agent = createTestAgent(issueProvider) - - // Create an already-claimed issue - val issue = issueProvider.createTestIssue( - title = "Add feature X", - body = "Implement feature X", - labels = listOf("assigned", "feature"), - ) - - // Try to claim the already-claimed issue - val result = agent.claimIssue(issue.number) - - // Verify claim failed - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull()?.message?.contains("already claimed") == true) - } - - @Test - fun `claimIssue fails when issue is in progress`() = runTest { - val issueProvider = TestIssueTrackerProvider() - val agent = createTestAgent(issueProvider) - - // Create an issue that's already in progress - val issue = issueProvider.createTestIssue( - title = "Refactor authentication module", - body = "Clean up the auth code", - labels = listOf("in-progress", "refactor"), - ) - - // Try to claim the in-progress issue - val result = agent.claimIssue(issue.number) - - // Verify claim failed - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull()?.message?.contains("IN_PROGRESS") == true) - } - - @Test - fun `claimIssue fails when issue is blocked`() = runTest { - val issueProvider = TestIssueTrackerProvider() - val agent = createTestAgent(issueProvider) - - // Create a blocked issue - val issue = issueProvider.createTestIssue( - title = "Implement payment integration", - body = "Blocked by missing API credentials", - labels = listOf("blocked", "feature"), - ) - - // Try to claim the blocked issue - val result = agent.claimIssue(issue.number) - - // Verify claim failed - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull()?.message?.contains("BLOCKED") == true) - } - - @Test - fun `claimIssue fails when issue is in review`() = runTest { - val issueProvider = TestIssueTrackerProvider() - val agent = createTestAgent(issueProvider) - - // Create an issue in review - val issue = issueProvider.createTestIssue( - title = "Optimize database queries", - body = "Make queries faster", - labels = listOf("in-review", "performance"), - ) - - // Try to claim the in-review issue - val result = agent.claimIssue(issue.number) - - // Verify claim failed - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull()?.message?.contains("IN_REVIEW") == true) - } - - @Test - fun `claimIssue fails when issue not found`() = runTest { - val issueProvider = TestIssueTrackerProvider() - val agent = createTestAgent(issueProvider) - - // Try to claim non-existent issue - val result = agent.claimIssue(999) - - // Verify claim failed - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull()?.message?.contains("not found") == true) - } - - @Test - fun `claimIssue detects race condition when another agent claims simultaneously`() = runTest { - val issueProvider = TestIssueTrackerProvider() - val agent = createTestAgent(issueProvider) - - // Create unassigned issue - val issue = issueProvider.createTestIssue( - title = "Add caching layer", - body = "Implement Redis caching", - labels = listOf("feature", "performance"), - ) - - // Simulate race condition by having the provider change labels after update but before verification - // We'll modify the test provider to inject a "concurrent claim" during the delay - issueProvider.simulateRaceCondition = true - issueProvider.raceConditionIssueNumber = issue.number - - // Try to claim - should detect the race condition - val result = agent.claimIssue(issue.number) - - // Verify claim failed due to race condition - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull()?.message?.contains("Race condition") == true) - } - - // ======================================================================== - // Integration Test Placeholders - // ======================================================================== - - /** - * PLACEHOLDER: Full workflow integration test. - * - * This test validates the complete issue-to-PR pipeline once all - * components are implemented: - * - Git tool execution (currently placeholders) - * - Full workflow orchestration - * - Event publishing - * - * TODO: Enable this test when: - * 1. Git tools execute actual git commands - * 2. Workflow orchestration runs full pipeline - * 3. Event bus captures workflow events - */ - @Ignore("Placeholder test - requires full workflow implementation") - @Test - fun `INTEGRATION - agent completes full issue-to-PR workflow`() = runTest { - val issueProvider = TestIssueTrackerProvider() - val agent = createTestAgent(issueProvider) - - // Create test issue - val issue = issueProvider.createTestIssue( - title = "Add fibonacci function", - body = "Create a function that returns the nth fibonacci number", - labels = listOf("code"), - assignee = "CodeWriterAgent", - ) - - // Execute full workflow using workOnIssue - val result = agent.workOnIssue(issue) - - // Verify workflow completed (either success or blocked with specific error) - // Note: This may succeed or fail depending on git environment and AI response - // The important thing is that it doesn't crash and updates issue status - assertNotNull(result) - - // Verify: Issue status was updated at some point during workflow - val updated = issueProvider.issues[issue.number] - assertNotNull(updated) - - // Issue should have some workflow label (assigned, in-progress, in-review, or blocked) - val hasWorkflowLabel = updated.labels.any { - it in listOf("assigned", "in-progress", "in-review", "blocked") - } - assertTrue(hasWorkflowLabel, "Issue should have a workflow status label after processing") - - // If result was successful, verify IN_REVIEW status - if (result.isSuccess) { - assertTrue( - updated.labels.contains("in-review"), - "Successful workflow should result in IN_REVIEW status", - ) - } - } - - /** - * PLACEHOLDER: Review feedback handling test. - * - * TODO: Implement when review feedback workflow is complete. - * This feature is not yet implemented, so test is disabled. - */ - // Future enhancement: Enable when review feedback workflow is implemented - // @Test - fun `INTEGRATION - agent addresses PR review feedback`() = runTest { - // TODO: Implement when review workflow is complete - // 1. Create PR with requested changes - // 2. Agent processes feedback - // 3. Verify status updates to CHANGES_REQUESTED - // 4. Verify new commits address feedback - - // For now, this is a placeholder for future functionality - assertTrue(true, "Review feedback workflow not yet implemented") - } -} diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/definition/SparkBasedAgentActivePromptProviderTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/definition/SparkBasedAgentActivePromptProviderTest.kt index 63cb25e6..c6592cb7 100644 --- a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/definition/SparkBasedAgentActivePromptProviderTest.kt +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/definition/SparkBasedAgentActivePromptProviderTest.kt @@ -5,8 +5,10 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest +import link.socket.ampere.agents.definition.code.CodeState import link.socket.ampere.agents.domain.cognition.CognitiveAffinity import link.socket.ampere.agents.domain.cognition.sparks.RoleSpark +import link.socket.ampere.agents.execution.tools.planning.PLAN_STEPS_TOOL_ID import link.socket.ampere.domain.ai.configuration.AIConfiguration import link.socket.ampere.domain.ai.model.AIModel import link.socket.ampere.domain.ai.model.AIModel_OpenAI @@ -34,6 +36,7 @@ class SparkBasedAgentActivePromptProviderTest { val agent = SparkBasedAgent( agentId = "test-spark-agent", cognitiveAffinity = CognitiveAffinity.ANALYTICAL, + initialState = CodeState.blank, _aiConfiguration = FakeAIConfiguration(), _llmProvider = provider, ) @@ -45,7 +48,7 @@ class SparkBasedAgentActivePromptProviderTest { "expected affinity header in payload, got: $firstPayload", ) - agent.spark(RoleSpark.Code) + agent.spark>(RoleSpark.Code) agent.callLLM("second") val secondPayload = captured.last() assertTrue( @@ -55,4 +58,20 @@ class SparkBasedAgentActivePromptProviderTest { assertEquals(2, captured.size) } + + @Test + fun `every SparkBasedAgent ships with the plan_steps tool by default`() { + val agent = SparkBasedAgent( + agentId = "default-tools-agent", + cognitiveAffinity = CognitiveAffinity.ANALYTICAL, + initialState = CodeState.blank, + _aiConfiguration = FakeAIConfiguration(), + ) + + val toolIds = agent.requiredTools.map { it.id }.toSet() + assertTrue( + PLAN_STEPS_TOOL_ID in toolIds, + "Spark-based agents should include the plan_steps tool by default; got $toolIds", + ) + } } diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/definition/SparkBasedAgentCodeFactoryTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/definition/SparkBasedAgentCodeFactoryTest.kt new file mode 100644 index 00000000..958f4a39 --- /dev/null +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/definition/SparkBasedAgentCodeFactoryTest.kt @@ -0,0 +1,124 @@ +package link.socket.ampere.agents.definition + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import link.socket.ampere.agents.config.AgentActionAutonomy +import link.socket.ampere.agents.domain.cognition.CognitiveAffinity +import link.socket.ampere.agents.domain.cognition.sparks.AmpereSpikeFlags +import link.socket.ampere.agents.domain.cognition.sparks.CognitivePhase +import link.socket.ampere.agents.domain.cognition.sparks.DefaultPhaseSparkLibrary +import link.socket.ampere.agents.domain.cognition.sparks.PhaseSparkManager +import link.socket.ampere.agents.domain.cognition.sparks.RoleSpark +import link.socket.ampere.agents.domain.cognition.sparks.SparkSelectionContext +import link.socket.ampere.agents.execution.request.ExecutionContext +import link.socket.ampere.agents.execution.tools.FunctionTool +import link.socket.ampere.agents.execution.tools.planning.PLAN_STEPS_TOOL_ID +import link.socket.ampere.domain.ai.configuration.AIConfiguration +import link.socket.ampere.domain.ai.model.AIModel +import link.socket.ampere.domain.ai.model.AIModel_OpenAI +import link.socket.ampere.domain.ai.provider.AIProvider + +/** + * Verifies the `SparkBasedAgent.Code(...)` factory produces an agent shaped + * like the legacy `CodeAgent`: ANALYTICAL affinity, `plan_steps` plus any + * caller-supplied tools, and a spark stack with `RoleSpark.Code` already on + * top of the affinity. Library wiring (for `code-agent.spark.md`) is a + * separate concern — exercised here via the internal setter. + */ +class SparkBasedAgentCodeFactoryTest { + + private class FakeAIConfiguration : AIConfiguration { + override val provider: AIProvider<*, *> + get() = throw NotImplementedError("provider should not be invoked in factory test") + override val model: AIModel + get() = AIModel_OpenAI.GPT_4_1 + + override fun getAvailableModels(): List, AIModel>> = emptyList() + } + + @Test + fun `factory builds an analytical code agent with the role spark on top`() { + val agent = SparkBasedAgent.Code( + agentId = "code-factory-test", + aiConfiguration = FakeAIConfiguration(), + ) + + assertEquals(CognitiveAffinity.ANALYTICAL, agent.affinity) + assertTrue( + agent.cognitiveState.endsWith("[${RoleSpark.Code.name}]"), + "RoleSpark.Code should be the most recently applied spark; got: ${agent.cognitiveState}", + ) + } + + @Test + fun `factory includes plan_steps and any caller-supplied tools`() { + val noopTool = FunctionTool( + id = "noop_for_test", + name = "Noop", + description = "test placeholder", + requiredAgentAutonomy = AgentActionAutonomy.FULLY_AUTONOMOUS, + executionFunction = { error("not used in this test") }, + ) + val agent = SparkBasedAgent.Code( + agentId = "code-factory-test-tools", + aiConfiguration = FakeAIConfiguration(), + tools = setOf(noopTool), + ) + + val toolIds = agent.requiredTools.map { it.id }.toSet() + assertTrue(PLAN_STEPS_TOOL_ID in toolIds, "plan_steps should be present by default; got $toolIds") + assertTrue("noop_for_test" in toolIds, "caller-supplied tools should be present; got $toolIds") + } + + @Test + fun `factory exposes the canonical code-agent spark id constant`() { + assertEquals("code-agent", SparkBasedAgent.CODE_AGENT_SPARK_ID) + } + + @Test + fun `wiring a PhaseSparkLibrary lets the code-agent declarative spark activate during a phase`() = runTest { + val agent = SparkBasedAgent.Code( + agentId = "code-factory-test-library", + aiConfiguration = FakeAIConfiguration(), + ) + val library = DefaultPhaseSparkLibrary.load() + agent.setPhaseSparkLibrary(library) + + // The code-agent spark must be in the loaded library. + val codeAgentSpark = library.byId(SparkBasedAgent.CODE_AGENT_SPARK_ID) + assertNotNull(codeAgentSpark, "code-agent.spark.md should be in the bundled library") + + // Drive a phase entry through the manager so the declarative spark + // is applied to the agent's stack; assert it's then present. + val manager = PhaseSparkManager.internalCreate( + agent = agent, + enabled = true, + library = library, + ) + val previousFlag = AmpereSpikeFlags.declarativeSparksEnabled + AmpereSpikeFlags.declarativeSparksEnabled = true + try { + manager.enterPhase( + phase = CognitivePhase.PLAN, + selectionContext = SparkSelectionContext( + phase = CognitivePhase.PLAN, + // Words chosen to overlap the code-agent spark's whenToUse + // text so the library actually selects it. + text = "write code in the workspace to commit changes", + ), + ) + val systemPrompt = agent.currentSystemPrompt + assertTrue( + systemPrompt.contains("plan_steps"), + "expected the code-agent spark's PLAN guidance (which references plan_steps) " + + "in the active system prompt, got: $systemPrompt", + ) + } finally { + AmpereSpikeFlags.declarativeSparksEnabled = previousFlag + manager.cleanup() + } + } +} diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/definition/SparkBasedAgentStepRoutingTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/definition/SparkBasedAgentStepRoutingTest.kt new file mode 100644 index 00000000..1605f2af --- /dev/null +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/definition/SparkBasedAgentStepRoutingTest.kt @@ -0,0 +1,198 @@ +package link.socket.ampere.agents.definition + +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock +import link.socket.ampere.agents.config.AgentActionAutonomy +import link.socket.ampere.agents.domain.expectation.Expectations +import link.socket.ampere.agents.domain.outcome.ExecutionOutcome +import link.socket.ampere.agents.domain.outcome.Outcome +import link.socket.ampere.agents.domain.reasoning.AgentReasoning +import link.socket.ampere.agents.domain.reasoning.Plan +import link.socket.ampere.agents.domain.status.TaskStatus +import link.socket.ampere.agents.domain.task.Task +import link.socket.ampere.agents.execution.request.ExecutionContext +import link.socket.ampere.agents.execution.request.ExecutionRequest +import link.socket.ampere.agents.execution.tools.FunctionTool +import link.socket.ampere.agents.execution.tools.Tool + +/** + * Exercises the AMPR-163 Task 5 routing contract on + * [SparkBasedAgent.runLLMToExecuteTask]: plan steps dispatch strictly by + * `Task.CodeChange.toolId` into the agent's `requiredTools`, with no + * keyword-routing fallback when the tool id is missing or unknown. + * + * The test wires a mock reasoning instance so it can produce arbitrary + * plans and observe tool invocations without standing up a real LLM or + * git workspace. + */ +class SparkBasedAgentStepRoutingTest { + + /** A recording tool that captures every invocation for later assertion. */ + private class RecordingTool(val id: String) { + val invocations: MutableList> = CopyOnWriteArrayList() + + val tool: FunctionTool = FunctionTool( + id = id, + name = "Recording $id", + description = "test recording tool", + requiredAgentAutonomy = AgentActionAutonomy.FULLY_AUTONOMOUS, + executionFunction = { request -> + invocations += request + ExecutionOutcome.NoChanges.Success( + executorId = request.context.executorId, + ticketId = request.context.ticket.id, + taskId = request.context.task.id, + executionStartTimestamp = Clock.System.now(), + executionEndTimestamp = Clock.System.now(), + message = "recorded by $id", + ) + }, + ) + } + + private fun planStep(id: String, description: String, toolId: String?): Task.CodeChange = + Task.CodeChange( + id = id, + status = TaskStatus.Pending, + description = description, + toolId = toolId, + ) + + private fun parentTask(description: String = "do the work"): Task.CodeChange = + Task.CodeChange( + id = "parent-task", + status = TaskStatus.Pending, + description = description, + ) + + @Test + fun `plan step nominating an available tool invokes that tool exactly once`() { + val recorder = RecordingTool(id = "git_commit") + val plan = Plan.ForTask( + task = parentTask(), + tasks = listOf(planStep("step-1-parent-task", "commit changes", toolId = "git_commit")), + estimatedComplexity = 1, + expectations = Expectations.blank, + ) + val reasoning = AgentReasoning.createForTesting(executorId = "routing-test") { + onPlanning { _, _ -> plan } + onToolExecution { _, request -> + @Suppress("UNCHECKED_CAST") + val typed = request as ExecutionRequest + runBlocking { recorder.tool.execute(typed) } as ExecutionOutcome + } + } + val agent = SparkBasedAgent.Code( + agentId = "routing-agent", + tools = setOf(recorder.tool), + reasoningOverride = reasoning, + ) + + val outcome = agent.runLLMToExecuteTask(parentTask()) + + assertEquals(1, recorder.invocations.size, "the nominated tool should be invoked exactly once") + assertTrue( + outcome is Outcome.Success, + "the run should succeed when the tool succeeded; got ${outcome::class.simpleName}", + ) + } + + @Test + fun `plan step nominating an unknown toolId fails fast with a clear error`() { + val recorder = RecordingTool(id = "git_commit") + val plan = Plan.ForTask( + task = parentTask(), + tasks = listOf(planStep("step-1-parent-task", "stage files", toolId = "git_stage")), + estimatedComplexity = 1, + expectations = Expectations.blank, + ) + val reasoning = AgentReasoning.createForTesting(executorId = "routing-test") { + onPlanning { _, _ -> plan } + onToolExecution { _, _ -> error("must not be reached when toolId is unknown") } + } + val agent = SparkBasedAgent.Code( + agentId = "routing-agent", + tools = setOf(recorder.tool), + reasoningOverride = reasoning, + ) + + val outcome = agent.runLLMToExecuteTask(parentTask()) + + assertEquals(0, recorder.invocations.size, "no tool should be invoked on routing failure") + assertTrue( + outcome is Outcome.Failure, + "missing tool routing should bubble up as a failure outcome; got ${outcome::class.simpleName}", + ) + } + + @Test + fun `plan step with null toolId is treated as a no-op reasoning step`() { + val recorder = RecordingTool(id = "git_commit") + val plan = Plan.ForTask( + task = parentTask(), + tasks = listOf(planStep("step-1-parent-task", "think about it", toolId = null)), + estimatedComplexity = 1, + expectations = Expectations.blank, + ) + val reasoning = AgentReasoning.createForTesting(executorId = "routing-test") { + onPlanning { _, _ -> plan } + onToolExecution { _, _ -> error("must not be reached when toolId is null") } + } + val agent = SparkBasedAgent.Code( + agentId = "routing-agent", + tools = setOf(recorder.tool), + reasoningOverride = reasoning, + ) + + val outcome = agent.runLLMToExecuteTask(parentTask()) + + assertEquals(0, recorder.invocations.size, "no-op steps must not invoke any tool") + assertTrue( + outcome is Outcome.Success, + "a plan of pure-reasoning steps should still succeed", + ) + } + + @Test + fun `multiple steps dispatch to their respective tools in order`() { + val first = RecordingTool(id = "git_stage") + val second = RecordingTool(id = "git_commit") + val plan = Plan.ForTask( + task = parentTask(), + tasks = listOf( + planStep("step-1-parent-task", "stage", toolId = "git_stage"), + planStep("step-2-parent-task", "commit", toolId = "git_commit"), + ), + estimatedComplexity = 2, + expectations = Expectations.blank, + ) + val reasoning = AgentReasoning.createForTesting(executorId = "routing-test") { + onPlanning { _, _ -> plan } + onToolExecution { tool, request -> + val recorder = when (tool.id) { + first.id -> first + second.id -> second + else -> error("unexpected tool id ${tool.id}") + } + + @Suppress("UNCHECKED_CAST") + val typed = request as ExecutionRequest + runBlocking { recorder.tool.execute(typed) } as ExecutionOutcome + } + } + val agent = SparkBasedAgent.Code( + agentId = "routing-agent", + tools = setOf>(first.tool, second.tool), + reasoningOverride = reasoning, + ) + + agent.runLLMToExecuteTask(parentTask()) + + assertEquals(1, first.invocations.size, "git_stage should fire once") + assertEquals(1, second.invocations.size, "git_commit should fire once") + } +} diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/demo/CognitiveCycleDemo.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/demo/CognitiveCycleDemo.kt index 1246d3b5..0913adda 100644 --- a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/demo/CognitiveCycleDemo.kt +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/demo/CognitiveCycleDemo.kt @@ -10,12 +10,10 @@ import kotlinx.coroutines.runBlocking import kotlinx.datetime.Clock import link.socket.ampere.agents.config.AgentActionAutonomy import link.socket.ampere.agents.config.AgentConfiguration -import link.socket.ampere.agents.definition.CodeAgent -import link.socket.ampere.agents.definition.code.CodeState +import link.socket.ampere.agents.definition.SparkBasedAgent import link.socket.ampere.agents.domain.outcome.ExecutionOutcome import link.socket.ampere.agents.domain.status.TaskStatus import link.socket.ampere.agents.domain.task.Task -import link.socket.ampere.agents.execution.executor.FunctionExecutor import link.socket.ampere.agents.execution.request.ExecutionContext import link.socket.ampere.agents.execution.results.ExecutionResult import link.socket.ampere.agents.execution.tools.FunctionTool @@ -63,13 +61,11 @@ class CognitiveCycleDemo { ), ) - // Create the CodeWriterAgent - val agent = CodeAgent( - initialState = CodeState.blank, - agentConfiguration = agentConfig, - toolWriteCodeFile = mockWriteCodeFile, - coroutineScope = this, - executor = FunctionExecutor.create(), + // Create the spark-based code agent + val agent = SparkBasedAgent.Code( + agentId = "cognitive-cycle-demo-agent", + aiConfiguration = agentConfig.aiConfiguration, + tools = setOf(mockWriteCodeFile), ) println("✓ Agent initialized: ${agent.id}") diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/AgentLearningLoopIntegrationTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/AgentLearningLoopIntegrationTest.kt deleted file mode 100644 index f2fd585d..00000000 --- a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/AgentLearningLoopIntegrationTest.kt +++ /dev/null @@ -1,200 +0,0 @@ -package link.socket.ampere.agents.domain - -import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertIs -import kotlin.test.assertTrue -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import link.socket.ampere.agents.definition.AgentId -import link.socket.ampere.agents.definition.ProductAgent -import link.socket.ampere.agents.definition.QualityAgent -import link.socket.ampere.agents.domain.knowledge.Knowledge -import link.socket.ampere.agents.domain.memory.KnowledgeWithScore -import link.socket.ampere.agents.domain.reasoning.Plan -import link.socket.ampere.agents.domain.status.TaskStatus -import link.socket.ampere.agents.domain.task.Task -import link.socket.ampere.agents.events.bus.EventSerialBus -import link.socket.ampere.agents.events.meetings.MeetingOrchestrator -import link.socket.ampere.agents.events.meetings.MeetingRepository -import link.socket.ampere.agents.events.meetings.MeetingSchedulingService -import link.socket.ampere.agents.events.messages.AgentMessageApi -import link.socket.ampere.agents.events.messages.MessageRepository -import link.socket.ampere.agents.events.tickets.TicketOrchestrator -import link.socket.ampere.agents.events.tickets.TicketRepository -import link.socket.ampere.data.DEFAULT_JSON -import link.socket.ampere.db.Database -import link.socket.ampere.stubFailureOutcome -import link.socket.ampere.stubKnowledgeEntry -import link.socket.ampere.stubProductManagerAgent -import link.socket.ampere.stubQualityAssuranceAgent -import link.socket.ampere.stubSuccessOutcome - -/** - * Integration tests for the full agent learning loop. - */ -class AgentLearningLoopIntegrationTest { - - private lateinit var driver: JdbcSqliteDriver - private lateinit var database: Database - - private lateinit var messageRepository: MessageRepository - private lateinit var meetingRepository: MeetingRepository - private lateinit var ticketRepository: TicketRepository - - private lateinit var eventSerialBus: EventSerialBus - private lateinit var messageApi: AgentMessageApi - private lateinit var meetingOrchestrator: MeetingOrchestrator - private lateinit var ticketOrchestrator: TicketOrchestrator - - private lateinit var productAgent: ProductAgent - private lateinit var qualityAgent: QualityAgent - - private val testScope = CoroutineScope(Dispatchers.Default) - private val stubOrchestratorAgentId: AgentId = "orchestrator-agent" - - @BeforeTest - fun setUp() { - driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) - Database.Schema.create(driver) - database = Database.Companion(driver) - - messageRepository = MessageRepository(DEFAULT_JSON, testScope, database) - meetingRepository = MeetingRepository(DEFAULT_JSON, testScope, database) - ticketRepository = TicketRepository(database) - - eventSerialBus = EventSerialBus(testScope) - messageApi = AgentMessageApi(stubOrchestratorAgentId, messageRepository, eventSerialBus) - - meetingOrchestrator = MeetingOrchestrator( - repository = meetingRepository, - eventSerialBus = eventSerialBus, - messageApi = messageApi, - ) - - // Create a MeetingSchedulingService that delegates to the meetingOrchestrator - val meetingSchedulingService = MeetingSchedulingService { meeting, scheduledBy -> - meetingOrchestrator.scheduleMeeting(meeting, scheduledBy) - } - - ticketOrchestrator = TicketOrchestrator( - ticketRepository = ticketRepository, - eventSerialBus = eventSerialBus, - messageApi = messageApi, - meetingSchedulingService = meetingSchedulingService, - ) - - productAgent = stubProductManagerAgent( - ticketOrchestrator = ticketOrchestrator, - ) - - qualityAgent = stubQualityAssuranceAgent() - } - - @AfterTest - fun tearDown() { - driver.close() - } - - @Test - fun `ProductManagerAgent learning loop - second task benefits from first`() = runBlocking { - // First task execution - val firstTask = Task.CodeChange( - id = "task-1", - status = TaskStatus.Pending, - description = "Implement authentication feature", - ) - - val firstPlan = productAgent.determinePlanForTask(firstTask, relevantKnowledge = emptyList()) - val firstOutcome = stubSuccessOutcome() - - // Extract knowledge from first execution - val extractedKnowledge = productAgent.extractKnowledgeFromOutcome( - firstOutcome, - firstTask, - Plan.ForTask( - task = firstTask, - tasks = listOf( - Task.CodeChange( - id = "subtask-1", - status = TaskStatus.Pending, - description = "Define test specifications", - ), - ), - ), - ) - - assertTrue(extractedKnowledge.approach.isNotEmpty()) - assertTrue(extractedKnowledge.learnings.contains("Success")) - - // Second task execution with recalled knowledge - val secondTask = Task.CodeChange( - id = "task-2", - status = TaskStatus.Pending, - description = "Implement authorization feature", - ) - - val recalledKnowledge = listOf( - KnowledgeWithScore( - entry = stubKnowledgeEntry( - id = "k1", - approach = extractedKnowledge.approach, - learnings = extractedKnowledge.learnings, - outcomeId = firstOutcome.id, - tags = listOf("success"), - taskType = "code_change", - ), - knowledge = extractedKnowledge, - relevanceScore = 0.9, - ), - ) - - val secondPlan = productAgent.determinePlanForTask(secondTask, relevantKnowledge = recalledKnowledge) - - assertTrue(secondPlan.tasks.isNotEmpty()) - } - - @Test - fun `Agents handle cold start with no knowledge`() = runBlocking { - val task = Task.CodeChange( - id = "cold-start-task", - status = TaskStatus.Pending, - description = "Implement new feature", - ) - - val pmPlan = productAgent.determinePlanForTask(task, relevantKnowledge = emptyList()) - val validationPlan = qualityAgent.determinePlanForTask(task, relevantKnowledge = emptyList()) - - assertTrue(pmPlan.tasks.isNotEmpty(), "PM should create plan without knowledge") - assertTrue(validationPlan.tasks.isNotEmpty(), "Validation should create plan without knowledge") - } - - @Test - fun `Extracted knowledge structure is consistent`() { - val task = Task.CodeChange( - id = "consistency-task", - status = TaskStatus.Pending, - description = "Test task", - ) - - val plan = Plan.ForTask(task = task) - - // Extract from success - val successOutcome = stubSuccessOutcome() - val successKnowledge = productAgent.extractKnowledgeFromOutcome(successOutcome, task, plan) - - // Extract from failure - val failureOutcome = stubFailureOutcome() - val failureKnowledge = productAgent.extractKnowledgeFromOutcome(failureOutcome, task, plan) - - val successFromOutcome = assertIs(successKnowledge) - val failureFromOutcome = assertIs(failureKnowledge) - assertTrue(successFromOutcome.approach.isNotEmpty()) - assertTrue(failureFromOutcome.approach.isNotEmpty()) - assertTrue(successFromOutcome.learnings.contains("Success")) - assertTrue(failureFromOutcome.learnings.contains("Failure")) - } -} diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/ProductManagerAgentTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/ProductManagerAgentTest.kt deleted file mode 100644 index 0693a226..00000000 --- a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/ProductManagerAgentTest.kt +++ /dev/null @@ -1,157 +0,0 @@ -package link.socket.ampere.agents.domain - -import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertIs -import kotlin.test.assertTrue -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.datetime.Clock -import link.socket.ampere.agents.definition.AgentId -import link.socket.ampere.agents.definition.ProductAgent -import link.socket.ampere.agents.domain.knowledge.Knowledge -import link.socket.ampere.agents.domain.memory.KnowledgeWithScore -import link.socket.ampere.agents.domain.reasoning.Plan -import link.socket.ampere.agents.domain.status.TaskStatus -import link.socket.ampere.agents.domain.task.Task -import link.socket.ampere.agents.events.bus.EventSerialBus -import link.socket.ampere.agents.events.meetings.MeetingOrchestrator -import link.socket.ampere.agents.events.meetings.MeetingRepository -import link.socket.ampere.agents.events.meetings.MeetingSchedulingService -import link.socket.ampere.agents.events.messages.AgentMessageApi -import link.socket.ampere.agents.events.messages.MessageRepository -import link.socket.ampere.agents.events.tickets.TicketOrchestrator -import link.socket.ampere.agents.events.tickets.TicketRepository -import link.socket.ampere.data.DEFAULT_JSON -import link.socket.ampere.db.Database -import link.socket.ampere.stubKnowledgeEntry -import link.socket.ampere.stubProductManagerAgent -import link.socket.ampere.stubSuccessOutcome - -class ProductManagerAgentTest { - - private lateinit var driver: JdbcSqliteDriver - private lateinit var database: Database - - private lateinit var messageRepository: MessageRepository - private lateinit var meetingRepository: MeetingRepository - private lateinit var ticketRepository: TicketRepository - - private lateinit var eventSerialBus: EventSerialBus - private lateinit var messageApi: AgentMessageApi - private lateinit var meetingOrchestrator: MeetingOrchestrator - private lateinit var ticketOrchestrator: TicketOrchestrator - - private lateinit var productAgent: ProductAgent - - private val testScope = CoroutineScope(Dispatchers.Default) - private val stubOrchestratorAgentId: AgentId = "orchestrator-agent" - - @BeforeTest - fun setUp() { - driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) - Database.Schema.create(driver) - database = Database.Companion(driver) - - messageRepository = MessageRepository(DEFAULT_JSON, testScope, database) - meetingRepository = MeetingRepository(DEFAULT_JSON, testScope, database) - ticketRepository = TicketRepository(database) - - eventSerialBus = EventSerialBus(testScope) - messageApi = AgentMessageApi(stubOrchestratorAgentId, messageRepository, eventSerialBus) - - meetingOrchestrator = MeetingOrchestrator( - repository = meetingRepository, - eventSerialBus = eventSerialBus, - messageApi = messageApi, - ) - - // Create a MeetingSchedulingService that delegates to the meetingOrchestrator - val meetingSchedulingService = MeetingSchedulingService { meeting, scheduledBy -> - meetingOrchestrator.scheduleMeeting(meeting, scheduledBy) - } - - ticketOrchestrator = TicketOrchestrator( - ticketRepository = ticketRepository, - eventSerialBus = eventSerialBus, - messageApi = messageApi, - meetingSchedulingService = meetingSchedulingService, - ) - - productAgent = stubProductManagerAgent( - ticketOrchestrator = ticketOrchestrator, - ) - } - - @AfterTest - fun tearDown() { - driver.close() - } - - @Test - fun `determinePlanForTask creates plan with knowledge`() = runBlocking { - val testFirstKnowledge = listOf( - KnowledgeWithScore( - entry = stubKnowledgeEntry( - id = "k1", - approach = "Used test-first approach with 3 tasks", - learnings = "Test-first approach prevented issues in authentication flow", - outcomeId = "outcome-1", - tags = listOf("success", "test"), - taskType = "code_change", - ), - knowledge = Knowledge.FromOutcome( - outcomeId = "outcome-1", - approach = "Used test-first approach with 3 tasks", - learnings = "Test-first approach prevented issues in authentication flow", - timestamp = Clock.System.now(), - ), - relevanceScore = 0.9, - ), - ) - - val task = Task.CodeChange( - id = "task-1", - status = TaskStatus.Pending, - description = "Implement user profile feature", - ) - - val plan = productAgent.determinePlanForTask(task, relevantKnowledge = testFirstKnowledge) - - assertTrue(plan.tasks.isNotEmpty(), "Plan should include tasks") - } - - @Test - fun `extractKnowledgeFromOutcome captures success learnings`() { - val task = Task.CodeChange( - id = "task-4", - status = TaskStatus.Pending, - description = "Implement authentication system", - ) - - val plan = Plan.ForTask(task = task) - val outcome = stubSuccessOutcome() - - val knowledge = productAgent.extractKnowledgeFromOutcome(outcome, task, plan) - - val fromOutcome = assertIs(knowledge) - assertTrue(fromOutcome.approach.isNotEmpty(), "Approach should be captured") - assertTrue(fromOutcome.learnings.contains("Success"), "Learnings should mention success") - } - - @Test - fun `determinePlanForTask handles empty knowledge`() = runBlocking { - val task = Task.CodeChange( - id = "task-6", - status = TaskStatus.Pending, - description = "Implement new feature", - ) - - val plan = productAgent.determinePlanForTask(task = task, relevantKnowledge = emptyList()) - - assertTrue(plan.tasks.isNotEmpty(), "Plan should include tasks even without knowledge") - } -} diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeSparkDemo.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeSparkDemo.kt index 396cf7ea..5e654e2f 100644 --- a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeSparkDemo.kt +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeSparkDemo.kt @@ -5,6 +5,7 @@ import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.Test import kotlinx.coroutines.test.runTest import link.socket.ampere.agents.definition.SparkBasedAgent +import link.socket.ampere.agents.definition.code.CodeState import link.socket.ampere.agents.domain.cognition.CognitiveAffinity import link.socket.ampere.agents.domain.cognition.Spark import link.socket.ampere.domain.ai.configuration.AIConfiguration @@ -34,9 +35,10 @@ class DeclarativeSparkDemo { private class RecordingSparkAgent( agentId: String, provider: LlmProvider, - ) : SparkBasedAgent( + ) : SparkBasedAgent( agentId = agentId, cognitiveAffinity = CognitiveAffinity.ANALYTICAL, + initialState = CodeState.blank, _aiConfiguration = FakeAIConfiguration(), _llmProvider = provider, ) { diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeSparkIntegrationTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeSparkIntegrationTest.kt index c0e4cdb5..23a7a367 100644 --- a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeSparkIntegrationTest.kt +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeSparkIntegrationTest.kt @@ -6,6 +6,7 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest import link.socket.ampere.agents.definition.SparkBasedAgent +import link.socket.ampere.agents.definition.code.CodeState import link.socket.ampere.agents.domain.cognition.CognitiveAffinity import link.socket.ampere.agents.domain.cognition.Spark import link.socket.ampere.domain.ai.configuration.AIConfiguration @@ -32,9 +33,10 @@ class DeclarativeSparkIntegrationTest { private class RecordingSparkAgent( agentId: String, provider: LlmProvider, - ) : SparkBasedAgent( + ) : SparkBasedAgent( agentId = agentId, cognitiveAffinity = CognitiveAffinity.ANALYTICAL, + initialState = CodeState.blank, _aiConfiguration = FakeAIConfiguration(), _llmProvider = provider, ) { diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/PhaseSparkLibraryTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/PhaseSparkLibraryTest.kt index eef5555d..620af100 100644 --- a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/PhaseSparkLibraryTest.kt +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/PhaseSparkLibraryTest.kt @@ -16,11 +16,49 @@ class PhaseSparkLibraryTest { val ids = sparks.map { it.sparkId }.toSet() assertEquals( - setOf("cooking-domain", "recipe-arc-task", "minimal-edge"), + setOf( + "cooking-domain", + "recipe-arc-task", + "minimal-edge", + "code-agent", + "product-agent", + "project-agent", + "quality-agent", + ), ids, ) } + @Test + fun `code-agent spark parses and exposes per-phase contributions`() = runTest { + val library = DefaultPhaseSparkLibrary.load() + + val codeAgent = library.byId("code-agent") + assertNotNull(codeAgent, "code-agent.spark.md should load via the default library") + assertTrue(codeAgent is DeclarativePhaseSpark) + + assertEquals( + setOf( + CognitivePhase.PERCEIVE, + CognitivePhase.PLAN, + CognitivePhase.EXECUTE, + CognitivePhase.LEARN, + ), + codeAgent.eligiblePhases.toSet(), + ) + // Planning section delegates the JSON shape to the plan_steps tool. + val planContribution = codeAgent.phaseContributions[CognitivePhase.PLAN] + assertNotNull(planContribution) + assertTrue( + planContribution.contains("plan_steps"), + "PLAN section should hand the JSON shape off to the plan_steps tool", + ) + assertTrue( + !planContribution.contains("estimatedComplexity"), + "PLAN section should no longer inline the JSON schema fields", + ) + } + @Test fun `byId returns a matching spark and null for unknown ids`() = runTest { val library = DefaultPhaseSparkLibrary.load() diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/events/meetings/MeetingParticipationHandlerTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/events/meetings/MeetingParticipationHandlerTest.kt index 17f43c41..82ff0632 100644 --- a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/events/meetings/MeetingParticipationHandlerTest.kt +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/events/meetings/MeetingParticipationHandlerTest.kt @@ -16,10 +16,8 @@ import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.json.Json import link.socket.ampere.agents.config.AgentActionAutonomy -import link.socket.ampere.agents.config.AgentConfiguration import link.socket.ampere.agents.definition.AgentId -import link.socket.ampere.agents.definition.CodeAgent -import link.socket.ampere.agents.definition.code.CodeState +import link.socket.ampere.agents.definition.SparkBasedAgent import link.socket.ampere.agents.domain.event.Event import link.socket.ampere.agents.domain.event.EventSource import link.socket.ampere.agents.domain.event.MeetingEvent @@ -32,7 +30,6 @@ import link.socket.ampere.agents.events.messages.AgentMessageApi import link.socket.ampere.agents.events.messages.MessageRepository import link.socket.ampere.agents.execution.tools.ToolWriteCodeFile import link.socket.ampere.db.Database -import link.socket.ampere.domain.agent.bundled.WriteCodeAgent import link.socket.ampere.domain.ai.configuration.AIConfiguration_Default import link.socket.ampere.domain.ai.model.AIModel_Gemini import link.socket.ampere.domain.ai.provider.AIProvider_Google @@ -57,19 +54,17 @@ class MeetingParticipationHandlerTest { ignoreUnknownKeys = true } - private val stubAgent = CodeAgent( - initialState = CodeState.blank, - agentConfiguration = AgentConfiguration( - agentDefinition = WriteCodeAgent, - aiConfiguration = AIConfiguration_Default( - provider = AIProvider_Google, - model = AIModel_Gemini.Pro_2_5, - ), + private val stubAgent = SparkBasedAgent.Code( + agentId = "meeting-stub-agent", + aiConfiguration = AIConfiguration_Default( + provider = AIProvider_Google, + model = AIModel_Gemini.Pro_2_5, ), - toolWriteCodeFile = ToolWriteCodeFile( - requiredAgentAutonomy = AgentActionAutonomy.ASK_BEFORE_ACTION, + tools = setOf( + ToolWriteCodeFile( + requiredAgentAutonomy = AgentActionAutonomy.ASK_BEFORE_ACTION, + ), ), - coroutineScope = testScope, ) private val stubScheduledBy = EventSource.Agent("scheduler-agent") diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/events/tickets/BacklogAnalyticsTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/events/tickets/BacklogAnalyticsTest.kt deleted file mode 100644 index 6243bc6e..00000000 --- a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/events/tickets/BacklogAnalyticsTest.kt +++ /dev/null @@ -1,764 +0,0 @@ -package link.socket.ampere.agents.events.tickets - -import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.days -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.datetime.Clock -import link.socket.ampere.agents.definition.AgentId -import link.socket.ampere.agents.definition.ProductAgent -import link.socket.ampere.agents.domain.status.TicketStatus -import link.socket.ampere.agents.events.bus.EventSerialBus -import link.socket.ampere.agents.events.escalation.Escalation -import link.socket.ampere.agents.events.meetings.MeetingOrchestrator -import link.socket.ampere.agents.events.meetings.MeetingRepository -import link.socket.ampere.agents.events.meetings.MeetingSchedulingService -import link.socket.ampere.agents.events.messages.AgentMessageApi -import link.socket.ampere.agents.events.messages.MessageRepository -import link.socket.ampere.data.DEFAULT_JSON -import link.socket.ampere.db.Database -import link.socket.ampere.stubProductManagerAgent - -class BacklogAnalyticsTest { - - private lateinit var driver: JdbcSqliteDriver - private lateinit var database: Database - - private lateinit var messageRepository: MessageRepository - private lateinit var meetingRepository: MeetingRepository - private lateinit var ticketRepository: TicketRepository - - private lateinit var eventSerialBus: EventSerialBus - private lateinit var messageApi: AgentMessageApi - private lateinit var meetingOrchestrator: MeetingOrchestrator - private lateinit var ticketOrchestrator: TicketOrchestrator - - private lateinit var productAgent: ProductAgent - - private val testScope = CoroutineScope(Dispatchers.Default) - private val stubOrchestratorAgentId: AgentId = "orchestrator-agent" - private val stubPmAgentId: AgentId = "pm-agent" - private val stubDevAgent1Id: AgentId = "dev-agent-1" - private val stubDevAgent2Id: AgentId = "dev-agent-2" - - @BeforeTest - fun setUp() { - driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) - Database.Schema.create(driver) - database = Database.Companion(driver) - - messageRepository = MessageRepository(DEFAULT_JSON, testScope, database) - meetingRepository = MeetingRepository(DEFAULT_JSON, testScope, database) - ticketRepository = TicketRepository(database) - - eventSerialBus = EventSerialBus(testScope) - messageApi = AgentMessageApi(stubOrchestratorAgentId, messageRepository, eventSerialBus) - - meetingOrchestrator = MeetingOrchestrator( - repository = meetingRepository, - eventSerialBus = eventSerialBus, - messageApi = messageApi, - ) - - // Create a MeetingSchedulingService that delegates to the meetingOrchestrator - val meetingSchedulingService = MeetingSchedulingService { meeting, scheduledBy -> - meetingOrchestrator.scheduleMeeting(meeting, scheduledBy) - } - - ticketOrchestrator = TicketOrchestrator( - ticketRepository = ticketRepository, - eventSerialBus = eventSerialBus, - messageApi = messageApi, - meetingSchedulingService = meetingSchedulingService, - ) - - productAgent = stubProductManagerAgent( - ticketOrchestrator = ticketOrchestrator, - ) - } - - @AfterTest - fun tearDown() { - driver.close() - } - - // ==================== BacklogSummary Tests ==================== - - @Test - fun `getBacklogSummary returns zeros with no tickets`() { - runBlocking { - val result = ticketOrchestrator.getBacklogSummary() - - assertTrue(result.isSuccess) - val summary = result.getOrNull()!! - - assertEquals(0, summary.totalTickets) - assertTrue(summary.ticketsByStatus.isEmpty()) - assertTrue(summary.ticketsByPriority.isEmpty()) - assertTrue(summary.ticketsByType.isEmpty()) - assertEquals(0, summary.blockedCount) - assertEquals(0, summary.overdueCount) - } - } - - @Test - fun `getBacklogSummary returns correct counts by status`() { - runBlocking { - // Create tickets in different statuses - // BACKLOG tickets - createTestTicket("Backlog 1", TicketType.FEATURE, TicketPriority.HIGH) - createTestTicket("Backlog 2", TicketType.BUG, TicketPriority.MEDIUM) - - // READY ticket - val readyTicket = createTestTicket("Ready 1", TicketType.TASK, TicketPriority.LOW) - ticketOrchestrator.transitionTicketStatus(readyTicket.id, TicketStatus.Ready, stubPmAgentId) - - // IN_PROGRESS ticket - val inProgressTicket = createTestTicket("In Progress 1", TicketType.SPIKE, TicketPriority.HIGH) - ticketOrchestrator.transitionTicketStatus(inProgressTicket.id, TicketStatus.Ready, stubPmAgentId) - ticketOrchestrator.transitionTicketStatus(inProgressTicket.id, TicketStatus.InProgress, stubPmAgentId) - - val result = ticketOrchestrator.getBacklogSummary() - - assertTrue(result.isSuccess) - val summary = result.getOrNull()!! - - assertEquals(4, summary.totalTickets) - assertEquals(2, summary.ticketsByStatus[TicketStatus.Backlog]) - assertEquals(1, summary.ticketsByStatus[TicketStatus.Ready]) - assertEquals(1, summary.ticketsByStatus[TicketStatus.InProgress]) - } - } - - @Test - fun `getBacklogSummary returns correct counts by priority`() { - runBlocking { - createTestTicket("Critical", TicketType.BUG, TicketPriority.CRITICAL) - createTestTicket("High 1", TicketType.FEATURE, TicketPriority.HIGH) - createTestTicket("High 2", TicketType.TASK, TicketPriority.HIGH) - createTestTicket("Medium", TicketType.SPIKE, TicketPriority.MEDIUM) - createTestTicket("Low", TicketType.TASK, TicketPriority.LOW) - - val result = ticketOrchestrator.getBacklogSummary() - - assertTrue(result.isSuccess) - val summary = result.getOrNull()!! - - assertEquals(5, summary.totalTickets) - assertEquals(1, summary.ticketsByPriority[TicketPriority.CRITICAL]) - assertEquals(2, summary.ticketsByPriority[TicketPriority.HIGH]) - assertEquals(1, summary.ticketsByPriority[TicketPriority.MEDIUM]) - assertEquals(1, summary.ticketsByPriority[TicketPriority.LOW]) - } - } - - @Test - fun `getBacklogSummary returns correct counts by type`() { - runBlocking { - createTestTicket("Feature 1", TicketType.FEATURE, TicketPriority.HIGH) - createTestTicket("Feature 2", TicketType.FEATURE, TicketPriority.MEDIUM) - createTestTicket("Bug 1", TicketType.BUG, TicketPriority.CRITICAL) - createTestTicket("Task 1", TicketType.TASK, TicketPriority.LOW) - createTestTicket("Spike 1", TicketType.SPIKE, TicketPriority.MEDIUM) - - val result = ticketOrchestrator.getBacklogSummary() - - assertTrue(result.isSuccess) - val summary = result.getOrNull()!! - - assertEquals(5, summary.totalTickets) - assertEquals(2, summary.ticketsByType[TicketType.FEATURE]) - assertEquals(1, summary.ticketsByType[TicketType.BUG]) - assertEquals(1, summary.ticketsByType[TicketType.TASK]) - assertEquals(1, summary.ticketsByType[TicketType.SPIKE]) - } - } - - @Test - fun `getBacklogSummary returns correct blocked count`() { - runBlocking { - // Create and block some tickets - val ticket1 = createTestTicket("Blocked 1", TicketType.BUG, TicketPriority.HIGH) - ticketOrchestrator.assignTicket(ticket1.id, stubDevAgent1Id, stubPmAgentId) - ticketOrchestrator.transitionTicketStatus(ticket1.id, TicketStatus.Ready, stubPmAgentId) - ticketOrchestrator.transitionTicketStatus(ticket1.id, TicketStatus.InProgress, stubPmAgentId) - ticketOrchestrator.blockTicket( - ticketId = ticket1.id, - blockingReason = "Waiting for API", - escalationType = Escalation.Budget.CostApproval, - reportedByAgentId = stubDevAgent1Id, - assignedToAgentId = stubDevAgent1Id, - ) - - val ticket2 = createTestTicket("Blocked 2", TicketType.FEATURE, TicketPriority.CRITICAL) - ticketOrchestrator.assignTicket(ticket2.id, stubDevAgent1Id, stubPmAgentId) - ticketOrchestrator.transitionTicketStatus(ticket2.id, TicketStatus.Ready, stubPmAgentId) - ticketOrchestrator.transitionTicketStatus(ticket2.id, TicketStatus.InProgress, stubPmAgentId) - ticketOrchestrator.blockTicket( - ticketId = ticket2.id, - blockingReason = "Waiting for external", - escalationType = Escalation.Budget.CostApproval, - reportedByAgentId = stubDevAgent1Id, - assignedToAgentId = stubDevAgent1Id, - ) - - // Not blocked - createTestTicket("Not Blocked", TicketType.TASK, TicketPriority.LOW) - - val result = ticketOrchestrator.getBacklogSummary() - - assertTrue(result.isSuccess) - val summary = result.getOrNull()!! - - assertEquals(3, summary.totalTickets) - assertEquals(2, summary.blockedCount) - } - } - - @Test - fun `getBacklogSummary returns correct overdue count`() { - runBlocking { - val now = Clock.System.now() - - // Create overdue ticket (due in past) - val overdueTicket = createTestTicketWithDueDate( - "Overdue", - TicketType.BUG, - TicketPriority.HIGH, - now - 1.days, - ) - - // Create ticket due in future (not overdue) - createTestTicketWithDueDate( - "Future", - TicketType.FEATURE, - TicketPriority.MEDIUM, - now + 7.days, - ) - - // Create ticket with no due date - createTestTicket("No Due Date", TicketType.TASK, TicketPriority.LOW) - - // Complete ticket that was overdue (should not count as overdue) - val completedOverdue = createTestTicketWithDueDate( - "Completed Overdue", - TicketType.TASK, - TicketPriority.LOW, - now - 2.days, - ) - ticketOrchestrator.transitionTicketStatus(completedOverdue.id, TicketStatus.Done, stubPmAgentId) - - val result = ticketOrchestrator.getBacklogSummary() - - assertTrue(result.isSuccess) - val summary = result.getOrNull()!! - - assertEquals(4, summary.totalTickets) - assertEquals(1, summary.overdueCount) - } - } - - // ==================== AgentWorkload Tests ==================== - - @Test - fun `getAgentWorkload returns empty workload for agent with no tickets`() { - runBlocking { - val result = ticketOrchestrator.getAgentWorkload(stubDevAgent1Id) - - assertTrue(result.isSuccess) - val workload = result.getOrNull()!! - - assertEquals(stubDevAgent1Id, workload.agentId) - assertTrue(workload.assignedTickets.isEmpty()) - assertEquals(0, workload.inProgressCount) - assertEquals(0, workload.blockedCount) - assertEquals(0, workload.completedCount) - assertEquals(0, workload.activeCount) - } - } - - @Test - fun `getAgentWorkload returns correct counts for assigned agent`() { - runBlocking { - // Create and assign tickets to dev-agent-1 - val ticket1 = createTestTicket("Ticket 1", TicketType.FEATURE, TicketPriority.HIGH) - ticketOrchestrator.assignTicket(ticket1.id, stubDevAgent1Id, stubPmAgentId) - - val ticket2 = createTestTicket("Ticket 2", TicketType.BUG, TicketPriority.CRITICAL) - ticketOrchestrator.assignTicket(ticket2.id, stubDevAgent1Id, stubPmAgentId) - ticketOrchestrator.transitionTicketStatus(ticket2.id, TicketStatus.Ready, stubPmAgentId) - ticketOrchestrator.transitionTicketStatus(ticket2.id, TicketStatus.InProgress, stubPmAgentId) - - val ticket3 = createTestTicket("Ticket 3", TicketType.TASK, TicketPriority.LOW) - ticketOrchestrator.assignTicket(ticket3.id, stubDevAgent1Id, stubPmAgentId) - ticketOrchestrator.transitionTicketStatus(ticket3.id, TicketStatus.Ready, stubPmAgentId) - ticketOrchestrator.transitionTicketStatus(ticket3.id, TicketStatus.InProgress, stubPmAgentId) - ticketOrchestrator.blockTicket( - ticketId = ticket3.id, - blockingReason = "Waiting", - escalationType = Escalation.Budget.CostApproval, - reportedByAgentId = stubDevAgent1Id, - assignedToAgentId = stubDevAgent1Id, - ) - - val ticket4 = createTestTicket("Ticket 4", TicketType.SPIKE, TicketPriority.MEDIUM) - ticketOrchestrator.assignTicket(ticket4.id, stubDevAgent1Id, stubPmAgentId) - ticketOrchestrator.transitionTicketStatus(ticket4.id, TicketStatus.Done, stubPmAgentId) - - // Assign ticket to different agent (should not count) - val ticket5 = createTestTicket("Other Agent", TicketType.TASK, TicketPriority.LOW) - ticketOrchestrator.assignTicket(ticket5.id, stubDevAgent2Id, stubPmAgentId) - - val result = ticketOrchestrator.getAgentWorkload(stubDevAgent1Id) - - assertTrue(result.isSuccess) - val workload = result.getOrNull()!! - - assertEquals(stubDevAgent1Id, workload.agentId) - assertEquals(4, workload.assignedTickets.size) - assertEquals(1, workload.inProgressCount) - assertEquals(1, workload.blockedCount) - assertEquals(1, workload.completedCount) - assertEquals(3, workload.activeCount) // 4 total - 1 completed - } - } - - @Test - fun `getAgentWorkload correctly distinguishes between agents`() { - runBlocking { - // Assign to agent 1 - val ticket1 = createTestTicket("Agent1 Ticket", TicketType.FEATURE, TicketPriority.HIGH) - ticketOrchestrator.assignTicket(ticket1.id, stubDevAgent1Id, stubPmAgentId) - - // Assign to agent 2 - val ticket2 = createTestTicket("Agent2 Ticket 1", TicketType.BUG, TicketPriority.MEDIUM) - ticketOrchestrator.assignTicket(ticket2.id, stubDevAgent2Id, stubPmAgentId) - - val ticket3 = createTestTicket("Agent2 Ticket 2", TicketType.TASK, TicketPriority.LOW) - ticketOrchestrator.assignTicket(ticket3.id, stubDevAgent2Id, stubPmAgentId) - - val workload1 = ticketOrchestrator.getAgentWorkload(stubDevAgent1Id).getOrNull()!! - val workload2 = ticketOrchestrator.getAgentWorkload(stubDevAgent2Id).getOrNull()!! - - assertEquals(1, workload1.assignedTickets.size) - assertEquals(2, workload2.assignedTickets.size) - } - } - - // ==================== UpcomingDeadlines Tests ==================== - - @Test - fun `getUpcomingDeadlines returns empty list when no tickets have due dates`() { - runBlocking { - createTestTicket("No Due Date 1", TicketType.FEATURE, TicketPriority.HIGH) - createTestTicket("No Due Date 2", TicketType.BUG, TicketPriority.MEDIUM) - - val result = ticketOrchestrator.getUpcomingDeadlines(7) - - assertTrue(result.isSuccess) - val deadlines = result.getOrNull()!! - - assertTrue(deadlines.isEmpty()) - } - } - - @Test - fun `getUpcomingDeadlines returns only tickets within specified days`() { - runBlocking { - val now = Clock.System.now() - - // Within 7 days - val ticket1 = createTestTicketWithDueDate( - "Due in 3 days", - TicketType.FEATURE, - TicketPriority.HIGH, - now + 3.days, - ) - - val ticket2 = createTestTicketWithDueDate( - "Due in 5 days", - TicketType.BUG, - TicketPriority.MEDIUM, - now + 5.days, - ) - - // Outside 7 days - createTestTicketWithDueDate( - "Due in 10 days", - TicketType.TASK, - TicketPriority.LOW, - now + 10.days, - ) - - // Past due (should not be included) - createTestTicketWithDueDate( - "Overdue", - TicketType.SPIKE, - TicketPriority.CRITICAL, - now - 1.days, - ) - - val result = ticketOrchestrator.getUpcomingDeadlines(7) - - assertTrue(result.isSuccess) - val deadlines = result.getOrNull()!! - - assertEquals(2, deadlines.size) - assertTrue(deadlines.any { it.id == ticket1.id }) - assertTrue(deadlines.any { it.id == ticket2.id }) - } - } - - @Test - fun `getUpcomingDeadlines returns tickets sorted by due date ascending`() { - runBlocking { - val now = Clock.System.now() - - createTestTicketWithDueDate( - "Due in 5 days", - TicketType.FEATURE, - TicketPriority.HIGH, - now + 5.days, - ) - - createTestTicketWithDueDate( - "Due in 1 day", - TicketType.BUG, - TicketPriority.MEDIUM, - now + 1.days, - ) - - createTestTicketWithDueDate( - "Due in 3 days", - TicketType.TASK, - TicketPriority.LOW, - now + 3.days, - ) - - val result = ticketOrchestrator.getUpcomingDeadlines(7) - - assertTrue(result.isSuccess) - val deadlines = result.getOrNull()!! - - assertEquals(3, deadlines.size) - assertEquals("Due in 1 day", deadlines[0].title) - assertEquals("Due in 3 days", deadlines[1].title) - assertEquals("Due in 5 days", deadlines[2].title) - } - } - - @Test - fun `getUpcomingDeadlines excludes completed tickets`() { - runBlocking { - val now = Clock.System.now() - - // Active ticket with due date - val activeTicket = createTestTicketWithDueDate( - "Active", - TicketType.FEATURE, - TicketPriority.HIGH, - now + 3.days, - ) - - // Completed ticket with due date (should not be included) - val completedTicket = createTestTicketWithDueDate( - "Completed", - TicketType.BUG, - TicketPriority.MEDIUM, - now + 2.days, - ) - ticketOrchestrator.transitionTicketStatus(completedTicket.id, TicketStatus.Done, stubPmAgentId) - - val result = ticketOrchestrator.getUpcomingDeadlines(7) - - assertTrue(result.isSuccess) - val deadlines = result.getOrNull()!! - - assertEquals(1, deadlines.size) - assertEquals(activeTicket.id, deadlines[0].id) - } - } - - @Test - fun `getUpcomingDeadlines respects daysAhead parameter`() { - runBlocking { - val now = Clock.System.now() - - createTestTicketWithDueDate( - "Due in 2 days", - TicketType.FEATURE, - TicketPriority.HIGH, - now + 2.days, - ) - - createTestTicketWithDueDate( - "Due in 4 days", - TicketType.BUG, - TicketPriority.MEDIUM, - now + 4.days, - ) - - // Test with 3 days ahead - val result = ticketOrchestrator.getUpcomingDeadlines(3) - - assertTrue(result.isSuccess) - val deadlines = result.getOrNull()!! - - assertEquals(1, deadlines.size) - assertEquals("Due in 2 days", deadlines[0].title) - } - } - - // ==================== ProductManagerAgent Tests ==================== - - @Test - fun `PM agent perceive returns empty state with no tickets`() { - runBlocking { - val state = productAgent.getCurrentState() - - assertEquals(0, state.backlogSummary.totalTickets) - assertTrue(state.agentWorkloads.isEmpty()) - assertTrue(state.upcomingDeadlines.isEmpty()) - assertTrue(state.blockedTickets.isEmpty()) - assertTrue(state.overdueTickets.isEmpty()) - } - } - - @Test - fun `PM agent perceive returns backlog summary`() { - runBlocking { - // Create various tickets - createTestTicket("Feature", TicketType.FEATURE, TicketPriority.HIGH) - createTestTicket("Bug", TicketType.BUG, TicketPriority.CRITICAL) - createTestTicket("Task", TicketType.TASK, TicketPriority.LOW) - - val state = productAgent.getCurrentState() - - assertEquals(3, state.backlogSummary.totalTickets) - assertEquals(3, state.backlogSummary.ticketsByStatus[TicketStatus.Backlog]) - } - } - - @Test - fun `PM agent perceive returns agent workloads when agents specified`() { - runBlocking { - // Assign tickets - val ticket1 = createTestTicket("Agent1 Work", TicketType.FEATURE, TicketPriority.HIGH) - ticketOrchestrator.assignTicket(ticket1.id, stubDevAgent1Id, stubPmAgentId) - - val ticket2 = createTestTicket("Agent2 Work", TicketType.BUG, TicketPriority.MEDIUM) - ticketOrchestrator.assignTicket(ticket2.id, stubDevAgent2Id, stubPmAgentId) - - val state = productAgent.getCurrentState() - - assertEquals(2, state.agentWorkloads.size) - assertNotNull(state.agentWorkloads[stubDevAgent1Id]) - assertNotNull(state.agentWorkloads[stubDevAgent2Id]) - assertEquals(1, state.agentWorkloads[stubDevAgent1Id]!!.assignedTickets.size) - assertEquals(1, state.agentWorkloads[stubDevAgent2Id]!!.assignedTickets.size) - } - } - - @Test - fun `PM agent perceive includes blocked tickets`() { - runBlocking { - // Create and block a ticket - val blockedTicket = createTestTicket("Blocked", TicketType.BUG, TicketPriority.HIGH) - ticketOrchestrator.assignTicket(blockedTicket.id, stubDevAgent1Id, stubPmAgentId) - ticketOrchestrator.transitionTicketStatus(blockedTicket.id, TicketStatus.Ready, stubPmAgentId) - ticketOrchestrator.transitionTicketStatus(blockedTicket.id, TicketStatus.InProgress, stubPmAgentId) - ticketOrchestrator.blockTicket( - ticketId = blockedTicket.id, - blockingReason = "Waiting", - escalationType = Escalation.Budget.CostApproval, - reportedByAgentId = stubDevAgent1Id, - assignedToAgentId = stubDevAgent1Id, - ) - - val state = productAgent.getCurrentState() - - assertEquals(1, state.blockedTickets.size) - assertEquals(blockedTicket.id, state.blockedTickets[0].id) - } - } - - @Test - fun `PM agent perceive includes overdue tickets`() { - runBlocking { - val now = Clock.System.now() - - // Create overdue ticket - val overdueTicket = createTestTicketWithDueDate( - "Overdue", - TicketType.FEATURE, - TicketPriority.HIGH, - now - 1.days, - ) - ticketOrchestrator.assignTicket(overdueTicket.id, stubDevAgent1Id, stubPmAgentId) - - val state = productAgent.getCurrentState() - - assertEquals(1, state.overdueTickets.size) - assertEquals(overdueTicket.id, state.overdueTickets[0].id) - } - } - - @Test - fun `PM agent perceive includes upcoming deadlines`() { - runBlocking { - val now = Clock.System.now() - - createTestTicketWithDueDate( - "Due Soon", - TicketType.FEATURE, - TicketPriority.HIGH, - now + 3.days, - ) - - val state = productAgent.getCurrentState() - - assertEquals(1, state.upcomingDeadlines.size) - assertEquals("Due Soon", state.upcomingDeadlines[0].title) - } - } - - @Test - fun `PM agent perceiveAsText returns formatted text`() { - runBlocking { - // Create test data - createTestTicket("Test Feature", TicketType.FEATURE, TicketPriority.HIGH) - - val state = productAgent.getCurrentState() - val perception = productAgent.perceiveState(state) - val titles = perception.ideas.map { it.name } - - assertTrue(titles.contains("PM Agent Perception State")) - assertTrue(titles.contains("Backlog Summary")) - assertTrue(titles.contains("Total Tickets: 1")) - } - } - - @Test - fun `PM perception text highlights blocked tickets`() { - runBlocking { - // Create and block a ticket - val blockedTicket = createTestTicket("Blocked Feature", TicketType.FEATURE, TicketPriority.HIGH) - ticketOrchestrator.assignTicket(blockedTicket.id, stubDevAgent1Id, stubPmAgentId) - ticketOrchestrator.transitionTicketStatus(blockedTicket.id, TicketStatus.Ready, stubPmAgentId) - ticketOrchestrator.transitionTicketStatus(blockedTicket.id, TicketStatus.InProgress, stubPmAgentId) - ticketOrchestrator.blockTicket( - ticketId = blockedTicket.id, - blockingReason = "Test", - escalationType = Escalation.Budget.CostApproval, - reportedByAgentId = stubDevAgent1Id, - assignedToAgentId = stubDevAgent1Id, - ) - - val state = productAgent.getCurrentState() - val perception = productAgent.perceiveState(state) - val titles = perception.ideas.map { it.name } - - assertTrue(titles.contains("BLOCKED TICKETS")) - assertTrue(titles.contains("Blocked Feature")) - } - } - - @Test - fun `BacklogSummary toPerceptionText formats correctly`() { - val summary = BacklogSummary( - totalTickets = 10, - ticketsByStatus = mapOf( - TicketStatus.Backlog to 5, - TicketStatus.InProgress to 3, - TicketStatus.Done to 2, - ), - ticketsByPriority = mapOf( - TicketPriority.HIGH to 4, - TicketPriority.MEDIUM to 6, - ), - ticketsByType = mapOf( - TicketType.FEATURE to 5, - TicketType.BUG to 5, - ), - blockedCount = 1, - overdueCount = 2, - ) - - val text = summary.toPerceptionText() - - assertTrue(text.contains("Backlog Summary")) - assertTrue(text.contains("Total Tickets: 10")) - assertTrue(text.contains("Blocked: 1")) - assertTrue(text.contains("Overdue: 2")) - } - - @Test - fun `AgentWorkload toPerceptionText formats correctly`() { - val now = Clock.System.now() - val workload = AgentWorkload( - agentId = stubDevAgent1Id, - assignedTickets = listOf( - Ticket( - id = "test-1", - title = "Test Ticket", - description = "Description", - type = TicketType.FEATURE, - priority = TicketPriority.HIGH, - status = TicketStatus.InProgress, - assignedAgentId = stubDevAgent1Id, - createdByAgentId = stubPmAgentId, - createdAt = now, - updatedAt = now, - ), - ), - inProgressCount = 1, - blockedCount = 0, - completedCount = 0, - ) - - val text = workload.toPerceptionText() - - assertTrue(text.contains("Agent Workload: $stubDevAgent1Id")) - assertTrue(text.contains("Total Assigned: 1")) - assertTrue(text.contains("In Progress: 1")) - assertTrue(text.contains("Test Ticket")) - } - - // ==================== Helper Methods ==================== - - private suspend fun createTestTicket( - title: String, - type: TicketType, - priority: TicketPriority, - ): Ticket { - val result = ticketOrchestrator.createTicket( - title = title, - description = "Test description for $title", - type = type, - priority = priority, - createdByAgentId = stubPmAgentId, - ) - return result.getOrNull()!!.first - } - - private suspend fun createTestTicketWithDueDate( - title: String, - type: TicketType, - priority: TicketPriority, - dueDate: kotlinx.datetime.Instant, - ): Ticket { - val ticket = createTestTicket(title, type, priority) - ticketRepository.updateTicketDetails( - ticketId = ticket.id, - dueDate = dueDate, - ) - return ticketRepository.getTicket(ticket.id).getOrNull()!! - } -} diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/execution/tools/CodeToolOwnedStrategiesTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/execution/tools/CodeToolOwnedStrategiesTest.kt new file mode 100644 index 00000000..359e463f --- /dev/null +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/execution/tools/CodeToolOwnedStrategiesTest.kt @@ -0,0 +1,48 @@ +package link.socket.ampere.agents.execution.tools + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import link.socket.ampere.agents.config.AgentActionAutonomy +import link.socket.ampere.agents.definition.code.CodeParams + +/** + * Confirms the AMPR-163 Task 4 contract: code tools ship with their own + * [link.socket.ampere.agents.execution.ParameterStrategy] so the + * `ToolExecutionEngine`'s tool-owned path resolves the strategy without an + * agent-side `registerStrategy(...)` call. + */ +class CodeToolOwnedStrategiesTest { + + @Test + fun `ToolWriteCodeFile carries CodeParams CodeWriting by default`() { + val tool = ToolWriteCodeFile(AgentActionAutonomy.ACT_WITH_NOTIFICATION) + assertEquals(WRITE_CODE_FILE_TOOL_ID, tool.id) + val strategy = assertNotNull(tool.parameterStrategy) + assertTrue( + strategy is CodeParams.CodeWriting, + "default strategy should be CodeParams.CodeWriting, got ${strategy::class.simpleName}", + ) + } + + @Test + fun `ToolReadCodeFile carries CodeParams CodeReading by default`() { + val tool = ToolReadCodeFile() + assertEquals(READ_CODE_FILE_TOOL_ID, tool.id) + val strategy = assertNotNull(tool.parameterStrategy) + assertTrue( + strategy is CodeParams.CodeReading, + "default strategy should be CodeParams.CodeReading, got ${strategy::class.simpleName}", + ) + } + + @Test + fun `callers can opt out of the default parameter strategy`() { + val noStrategy = ToolWriteCodeFile( + requiredAgentAutonomy = AgentActionAutonomy.ACT_WITH_NOTIFICATION, + parameterStrategy = null, + ) + assertEquals(null, noStrategy.parameterStrategy) + } +} diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/execution/tools/planning/ToolPlanStepsTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/execution/tools/planning/ToolPlanStepsTest.kt new file mode 100644 index 00000000..42191000 --- /dev/null +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/execution/tools/planning/ToolPlanStepsTest.kt @@ -0,0 +1,172 @@ +package link.socket.ampere.agents.execution.tools.planning + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock +import link.socket.ampere.agents.config.AgentActionAutonomy +import link.socket.ampere.agents.domain.outcome.ExecutionOutcome +import link.socket.ampere.agents.domain.reasoning.Plan +import link.socket.ampere.agents.domain.status.TaskStatus +import link.socket.ampere.agents.domain.status.TicketStatus +import link.socket.ampere.agents.domain.task.Task +import link.socket.ampere.agents.events.tickets.Ticket +import link.socket.ampere.agents.events.tickets.TicketPriority +import link.socket.ampere.agents.events.tickets.TicketType +import link.socket.ampere.agents.execution.request.ExecutionConstraints +import link.socket.ampere.agents.execution.request.ExecutionContext +import link.socket.ampere.agents.execution.request.ExecutionRequest + +/** + * Verifies that the agent-neutral `plan_steps` tool owns its planning prompt + * and JSON schema end-to-end — neither belongs on any per-agent profile. + */ +class ToolPlanStepsTest { + + private fun planningContext( + intent: String = "implement a fizzbuzz function", + tools: List = listOf( + ExecutionContext.ToolDescriptor( + id = "write_code_file", + name = "Write Code File", + description = "Writes a code file in the current workspace.", + ), + ExecutionContext.ToolDescriptor( + id = "git_commit", + name = "Commit Changes", + description = "Commits staged changes.", + ), + ), + ): ExecutionContext.Planning { + val ticket = Ticket( + id = "ticket-1", + title = "Ship fizzbuzz", + description = intent, + type = TicketType.TASK, + priority = TicketPriority.LOW, + status = TicketStatus.Ready, + assignedAgentId = null, + createdByAgentId = "test-agent", + createdAt = Clock.System.now(), + updatedAt = Clock.System.now(), + ) + val task = Task.CodeChange( + id = "task-1", + status = TaskStatus.Pending, + description = intent, + ) + return ExecutionContext.Planning( + executorId = "test-agent", + ticket = ticket, + task = task, + instructions = intent, + agentRole = "Code Writer", + ideaSummary = "Existing fizzbuzz tests are red.", + knowledgeSummary = "Past: tests-first approach worked.", + availableToolDescriptors = tools, + ) + } + + @Test + fun `tool factory wires the strategy and exposes the canonical id`() { + val tool = ToolPlanSteps() + assertEquals(PLAN_STEPS_TOOL_ID, tool.id) + assertEquals(AgentActionAutonomy.FULLY_AUTONOMOUS, tool.requiredAgentAutonomy) + assertNotNull(tool.parameterStrategy) + assertTrue(tool.parameterStrategy is PlanStepsStrategy) + } + + @Test + fun `buildPrompt embeds the JSON schema and available tool ids`() { + val tool = ToolPlanSteps() + val strategy = tool.parameterStrategy as PlanStepsStrategy + val context = planningContext() + val request = ExecutionRequest( + context = context, + constraints = ExecutionConstraints(), + ) + + val prompt = strategy.buildPrompt(tool, request, intent = context.instructions) + + assertTrue(prompt.contains("\"steps\""), "prompt should declare the steps array") + assertTrue(prompt.contains("\"toolToUse\""), "prompt should call out toolToUse") + assertTrue(prompt.contains("\"estimatedComplexity\""), "prompt should call out estimatedComplexity") + assertTrue(prompt.contains("write_code_file"), "prompt should surface the available tool ids") + assertTrue( + prompt.contains("autonomous Code Writer agent"), + "prompt should personalise to the agent role from the context", + ) + assertTrue( + prompt.contains("implement a fizzbuzz function"), + "prompt should include the task intent", + ) + } + + @Test + fun `parseAndEnrichRequest builds a Plan from a well-formed JSON response`() { + val tool = ToolPlanSteps() + val strategy = tool.parameterStrategy as PlanStepsStrategy + val context = planningContext() + val request = ExecutionRequest( + context = context, + constraints = ExecutionConstraints(), + ) + + val response = """ + { + "steps": [ + {"description": "write fizzbuzz function", "toolToUse": "write_code_file", "requiresPreviousStep": false}, + {"description": "commit changes", "toolToUse": "git_commit", "requiresPreviousStep": true} + ], + "estimatedComplexity": 3 + } + """.trimIndent() + + val enriched = strategy.parseAndEnrichRequest(response, request) + val enrichedContext = enriched.context as ExecutionContext.Planning + val plan = enrichedContext.parsedPlan + assertNotNull(plan) + assertTrue(plan is Plan.ForTask) + assertEquals(2, plan.tasks.size) + assertEquals(3, plan.estimatedComplexity) + } + + @Test + fun `execute returns a planning Success when the strategy populated parsedPlan`() = runTest { + val tool = ToolPlanSteps() + val context = planningContext() + + val strategy = tool.parameterStrategy as PlanStepsStrategy + val response = """ + { + "steps": [{"description": "do the thing", "toolToUse": null, "requiresPreviousStep": false}], + "estimatedComplexity": 1 + } + """.trimIndent() + val enriched = strategy.parseAndEnrichRequest( + response, + ExecutionRequest(context = context, constraints = ExecutionConstraints()), + ) + + @Suppress("UNCHECKED_CAST") + val typedEnriched = enriched as ExecutionRequest + + val outcome = tool.execute(typedEnriched) + assertTrue(outcome is ExecutionOutcome.Planning.Success) + assertEquals(1, outcome.plan.tasks.size) + } + + @Test + fun `execute fails fast when the strategy did not populate parsedPlan`() = runTest { + val tool = ToolPlanSteps() + val request = ExecutionRequest( + context = planningContext(), + constraints = ExecutionConstraints(), + ) + + val outcome = tool.execute(request) + assertTrue(outcome is ExecutionOutcome.Planning.Failure) + } +} diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/implementations/CodeWriterAgentTest.jvm.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/implementations/CodeWriterAgentTest.jvm.kt deleted file mode 100644 index 08ba533b..00000000 --- a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/implementations/CodeWriterAgentTest.jvm.kt +++ /dev/null @@ -1,1750 +0,0 @@ -package link.socket.ampere.agents.implementations - -import java.nio.file.Path -import kotlin.io.path.absolutePathString -import kotlin.io.path.createTempDirectory -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.datetime.Clock -import link.socket.ampere.agents.config.AgentActionAutonomy -import link.socket.ampere.agents.config.AgentConfiguration -import link.socket.ampere.agents.definition.CodeAgent -import link.socket.ampere.agents.definition.code.CodeState -import link.socket.ampere.agents.domain.error.ExecutionError -import link.socket.ampere.agents.domain.expectation.Expectations -import link.socket.ampere.agents.domain.knowledge.Knowledge -import link.socket.ampere.agents.domain.outcome.ExecutionOutcome -import link.socket.ampere.agents.domain.outcome.Outcome -import link.socket.ampere.agents.domain.reasoning.Idea -import link.socket.ampere.agents.domain.reasoning.Perception -import link.socket.ampere.agents.domain.reasoning.Plan -import link.socket.ampere.agents.domain.status.TaskStatus -import link.socket.ampere.agents.domain.status.TicketStatus -import link.socket.ampere.agents.domain.task.AssignedTo -import link.socket.ampere.agents.domain.task.MeetingTask -import link.socket.ampere.agents.domain.task.Task -import link.socket.ampere.agents.environment.workspace.ExecutionWorkspace -import link.socket.ampere.agents.events.tickets.Ticket -import link.socket.ampere.agents.events.tickets.TicketPriority -import link.socket.ampere.agents.events.tickets.TicketType -import link.socket.ampere.agents.execution.request.ExecutionConstraints -import link.socket.ampere.agents.execution.request.ExecutionContext -import link.socket.ampere.agents.execution.request.ExecutionRequest -import link.socket.ampere.agents.execution.results.ExecutionResult -import link.socket.ampere.agents.execution.tools.Tool -import link.socket.ampere.agents.execution.tools.ToolWriteCodeFile -import link.socket.ampere.domain.agent.bundled.WriteCodeAgent -import link.socket.ampere.domain.ai.configuration.AIConfiguration -import link.socket.ampere.domain.ai.model.AIModel -import link.socket.ampere.domain.ai.model.AIModel_OpenAI -import link.socket.ampere.domain.ai.provider.AIProvider - -@OptIn(ExperimentalCoroutinesApi::class) -actual class CodeWriterAgentTest { - - private val stubTicket = Ticket( - id = "TestTicket", - title = "TestTitle", - description = "TestDescription", - type = TicketType.TASK, - priority = TicketPriority.LOW, - status = TicketStatus.Ready, - assignedAgentId = "TestAgent", - createdByAgentId = "TestAgent", - createdAt = Clock.System.now(), - updatedAt = Clock.System.now() + 1.seconds, - dueDate = Clock.System.now() + 1.minutes, - ) - - private val stubTask = Task.CodeChange( - id = "TestTask", - status = TaskStatus.InProgress, - description = "TestDescription", - assignedTo = AssignedTo.Agent("TestAgent"), - ) - - private val stubTool = ToolWriteCodeFile( - requiredAgentAutonomy = AgentActionAutonomy.ASK_BEFORE_ACTION, - ) - - private lateinit var executionRequest: ExecutionRequest - - private val testScope = TestScope(UnconfinedTestDispatcher()) - private lateinit var tempDir: Path - - // ==================== FAKE IMPLEMENTATIONS ==================== - - /** - * Fake AI configuration for testing. - * - * Since AIProvider is a sealed interface from commonMain, we cannot extend it - * from the test module. Instead, we throw NotImplementedError for methods that - * won't be called in these tests. - */ - private class FakeAIConfiguration : AIConfiguration { - override val provider: AIProvider<*, *> - get() = throw NotImplementedError("Provider not needed for these tests") - override val model: AIModel - get() = AIModel_OpenAI.GPT_4_1 - - override fun getAvailableModels(): List, AIModel>> = emptyList() - } - - /** - * Test agent that allows us to control the perception evaluation result - * without actually calling the LLM. - */ - private class TestableCodeAgent( - initialState: CodeState, - agentConfiguration: AgentConfiguration, - toolWriteCodeFile: Tool, - coroutineScope: CoroutineScope, - private val perceptionResult: (Perception) -> Idea, - private val planningResult: ((Task, List) -> Plan)? = null, - ) : CodeAgent(agentConfiguration, toolWriteCodeFile, coroutineScope, initialState) { - - override val runLLMToEvaluatePerception: (perception: Perception) -> Idea = - perceptionResult - - override val runLLMToPlan: (task: Task, ideas: List) -> Plan = - planningResult ?: super.runLLMToPlan - } - - @BeforeTest - fun setup() { - tempDir = createTempDirectory(prefix = "ampere-agent-test-") - - executionRequest = ExecutionRequest( - context = ExecutionContext.Code.WriteCode( - executorId = "executor-1", - ticket = stubTicket, - task = stubTask, - instructions = "Write a function", - workspace = ExecutionWorkspace(tempDir.absolutePathString()), - instructionsPerFilePath = listOf(), - ), - constraints = ExecutionConstraints(), - ) - } - - @AfterTest - fun tearDown() { - tempDir.toFile().deleteRecursively() - } - - // ==================== HELPER METHODS ==================== - - /** - * Creates a testable agent with controlled perception results. - * - * @param perceptionResult A function that produces Ideas based on perception - * @return A CodeWriterAgent configured for testing - */ - private fun createTestAgent( - perceptionResult: (Perception) -> Idea, - ): TestableCodeAgent { - val aiConfig = FakeAIConfiguration() - val agentConfig = AgentConfiguration( - agentDefinition = WriteCodeAgent, - aiConfiguration = aiConfig, - ) - - return TestableCodeAgent( - initialState = CodeState.blank, - agentConfiguration = agentConfig, - toolWriteCodeFile = stubTool, - coroutineScope = testScope, - perceptionResult = perceptionResult, - ) - } - - /** - * Creates a testable agent with controlled perception and planning results. - * - * @param perceptionResult A function that produces Ideas based on perception - * @param planningResult A function that produces Plans based on tasks and ideas - * @return A CodeWriterAgent configured for testing - */ - private fun createTestAgentWithPlanning( - perceptionResult: (Perception) -> Idea, - planningResult: (Task, List) -> Plan, - ): TestableCodeAgent { - val aiConfig = FakeAIConfiguration() - val agentConfig = AgentConfiguration( - agentDefinition = WriteCodeAgent, - aiConfiguration = aiConfig, - ) - - return TestableCodeAgent( - initialState = CodeState.blank, - agentConfiguration = agentConfig, - toolWriteCodeFile = stubTool, - coroutineScope = testScope, - perceptionResult = perceptionResult, - planningResult = planningResult, - ) - } - - /** - * Creates an Idea simulating LLM insight generation about a pending task. - */ - private fun createPendingTaskIdea(perception: Perception): Idea { - val task = perception.currentState.getCurrentMemory().task - return Idea( - name = "Perception analysis for pending task", - description = "Agent has a pending code change task → " + - "Should plan implementation steps for the task (confidence: high)", - ) - } - - /** - * Creates an Idea simulating LLM detection of failure patterns. - */ - private fun createFailurePatternIdea(perception: Perception): Idea { - return Idea( - name = "Perception analysis for pattern detection", - description = """ - Three consecutive failures detected in past outcomes → Should consider alternative approach or request human assistance (confidence: high) - - Pattern suggests tool may not be suitable for this task → May need different tool or different task decomposition (confidence: medium) - """.trimIndent(), - ) - } - - /** - * Creates an Idea for empty/idle state. - */ - private fun createEmptyStateIdea(perception: Perception): Idea { - return Idea( - name = "Perception analysis for current task", - description = "Agent has no active task → Awaiting new task assignment (confidence: high)", - ) - } - - /** - * Creates an Idea about available tools. - */ - private fun createToolAvailabilityIdea(perception: Perception): Idea { - return Idea( - name = "Perception analysis for code writing", - description = "WriteCodeFile tool is available → Can execute code writing tasks (confidence: high)", - ) - } - - /** - * Creates an Idea about successful patterns. - */ - private fun createSuccessPatternIdea(perception: Perception): Idea { - return Idea( - name = "Perception analysis for similar task", - description = "Previous similar task completed successfully → " + - "Can use similar approach for current task (confidence: high)", - ) - } - - // ==================== TESTS ==================== - - /** - * Test: Perception with simple pending task generates relevant idea - * - * Validates that the agent can evaluate a simple state with one pending task - * and generate an idea that references the task. - */ - @Test - fun `perception with simple pending task generates relevant idea`() { - // Setup: Create agent that simulates LLM insight generation - val agent = createTestAgent(::createPendingTaskIdea) - - // Create a state with one pending task - val state = CodeState.blank - state.setNewTask( - Task.CodeChange( - id = "task-1", - status = TaskStatus.Pending, - description = "Implement a user authentication function", - ), - ) - - val perception = Perception( - currentState = state, - ideas = emptyList(), - ) - - // Execute - val idea = agent.runLLMToEvaluatePerception(perception) - - // Verify - assertNotNull(idea) - assertNotEquals("", idea.name) - assertNotEquals("", idea.description) - assertContains(idea.description.lowercase(), "pending", ignoreCase = true) - } - - /** - * Test: Perception detects failure patterns - * - * Validates that the agent recognizes patterns of repeated failures and - * mentions them in the generated idea. - */ - @Test - fun `perception detects failure patterns in execution history`() { - // Setup: Create agent that simulates failure pattern detection - val agent = createTestAgent(::createFailurePatternIdea) - - // Create a state with failure outcomes - val state = CodeState.blank - state.setNewTask( - Task.CodeChange( - id = "task-retry", - status = TaskStatus.InProgress, - description = "Retry failed task", - ), - ) - - // Add knowledge from failed outcomes - state.addToPastKnowledge( - rememberedKnowledgeFromOutcomes = listOf( - Knowledge.FromOutcome( - outcomeId = "outcome-1", - approach = "Attempted direct implementation", - learnings = "Failed due to missing context", - timestamp = Clock.System.now(), - ), - Knowledge.FromOutcome( - outcomeId = "outcome-2", - approach = "Attempted with different parameters", - learnings = "Still failed with similar error", - timestamp = Clock.System.now(), - ), - Knowledge.FromOutcome( - outcomeId = "outcome-3", - approach = "Attempted with retry logic", - learnings = "Continued to fail", - timestamp = Clock.System.now(), - ), - ), - ) - - val perception = Perception( - currentState = state, - ideas = emptyList(), - ) - - // Execute - val idea = agent.runLLMToEvaluatePerception(perception) - - // Verify - assertNotNull(idea) - assertTrue( - idea.description.contains("failure", ignoreCase = true) || - idea.description.contains("alternative", ignoreCase = true) || - idea.description.contains("pattern", ignoreCase = true), - "Idea should mention failures or alternative approaches", - ) - } - - /** - * Test: Perception with empty state - * - * Validates that the agent handles an empty state gracefully without crashing. - */ - @Test - fun `perception with empty state returns graceful idea`() { - // Setup: Create agent for empty state - val agent = createTestAgent(::createEmptyStateIdea) - - // Create empty state - val state = CodeState.blank - - val perception = Perception( - currentState = state, - ideas = emptyList(), - ) - - // Execute - val idea = agent.runLLMToEvaluatePerception(perception) - - // Verify - assertNotNull(idea) - assertNotEquals("", idea.name) - assertNotEquals("", idea.description) - } - - /** - * Test: Perception handles errors gracefully with fallback - * - * Validates that the agent uses fallback logic when perception evaluation fails. - * This simulates scenarios like LLM errors or malformed responses. - */ - @Test - fun `perception handles errors with fallback`() { - // Setup: Create agent that returns a fallback idea - val agent = createTestAgent { perception -> - val task = perception.currentState.getCurrentMemory().task - Idea( - name = "Basic perception (fallback)", - description = "Code change task: Test task (Status: Pending)\n\n" + - "Note: Advanced perception analysis unavailable\n\n" + - "Available tools: ToolWriteCodeFile", - ) - } - - val state = CodeState.blank - state.setNewTask( - Task.CodeChange( - id = "task-1", - status = TaskStatus.Pending, - description = "Test task", - ), - ) - - val perception = Perception( - currentState = state, - ideas = emptyList(), - ) - - // Execute - should not crash - val idea = agent.runLLMToEvaluatePerception(perception) - - // Verify - should get fallback idea - assertNotNull(idea) - assertNotEquals("", idea.name) - assertNotEquals("", idea.description) - // Fallback ideas typically mention the current task - assertTrue( - idea.description.contains("task", ignoreCase = true) || - idea.description.contains("fallback", ignoreCase = true), - ) - } - - /** - * Test: Perception identifies available tools - * - * Validates that the perception process is aware of and mentions available tools. - */ - @Test - fun `perception identifies available tools`() { - // Setup: Create agent that identifies tools - val agent = createTestAgent(::createToolAvailabilityIdea) - - val state = CodeState.blank - state.setNewTask( - Task.CodeChange( - id = "task-1", - status = TaskStatus.Pending, - description = "Write a new function", - ), - ) - - val perception = Perception( - currentState = state, - ideas = emptyList(), - ) - - // Execute - val idea = agent.runLLMToEvaluatePerception(perception) - - // Verify - assertNotNull(idea) - // The idea should reference tools or capability - assertTrue( - idea.description.contains("tool", ignoreCase = true) || - idea.description.contains("available", ignoreCase = true) || - idea.description.contains("code", ignoreCase = true), - ) - } - - /** - * Test: Perception with successful pattern recognition - * - * Validates that the agent can recognize successful patterns and reference them. - */ - @Test - fun `perception references successful patterns from past knowledge`() { - // Setup: Create agent that recognizes success patterns - val agent = createTestAgent(::createSuccessPatternIdea) - - val state = CodeState.blank - state.setNewTask( - Task.CodeChange( - id = "task-2", - status = TaskStatus.Pending, - description = "Implement another authentication function", - ), - ) - - // Add successful knowledge - state.addToPastKnowledge( - rememberedKnowledgeFromOutcomes = listOf( - Knowledge.FromOutcome( - outcomeId = "outcome-success", - approach = "Used step-by-step implementation with tests", - learnings = "Approach worked well for authentication functions", - timestamp = Clock.System.now(), - ), - ), - ) - - val perception = Perception( - currentState = state, - ideas = emptyList(), - ) - - // Execute - val idea = agent.runLLMToEvaluatePerception(perception) - - // Verify - assertNotNull(idea) - assertTrue( - idea.description.contains("success", ignoreCase = true) || - idea.description.contains("similar", ignoreCase = true) || - idea.description.contains("approach", ignoreCase = true), - ) - } - - // ==================== PLANNING TESTS ==================== - - /** - * Test: Planning for simple single-step task - * - * Validates that planning generates a plan with one concrete step for a simple task. - */ - @Test - fun `planning for simple task generates single-step plan`() { - // Setup: Create agent with mock planning that returns a single-step plan - val agent = createTestAgentWithPlanning( - perceptionResult = ::createPendingTaskIdea, - planningResult = { task, ideas -> - Plan.ForTask( - task = task, - tasks = listOf( - Task.CodeChange( - id = "step-1-${task.id}", - status = TaskStatus.Pending, - description = "Write a hello world function", - assignedTo = AssignedTo.Agent("TestAgent"), - ), - ), - estimatedComplexity = 1, - expectations = Expectations.blank, - ) - }, - ) - - val simpleTask = Task.CodeChange( - id = "task-simple", - status = TaskStatus.Pending, - description = "Create a hello world function", - ) - - val basicIdeas = listOf( - Idea( - name = "Simple task", - description = "This is a straightforward single-function task", - ), - ) - - // Execute - val plan = agent.runLLMToPlan(simpleTask, basicIdeas) - - // Verify - assertNotNull(plan) - assertTrue(plan is Plan.ForTask) - kotlin.test.assertEquals(1, plan.tasks.size, "Simple task should have 1 step") - assertTrue(plan.estimatedComplexity <= 3, "Simple task should have low complexity") - } - - /** - * Test: Planning creates multi-step plan for complex tasks - * - * Validates that planning breaks down complex tasks into multiple steps. - */ - @Test - fun `planning for complex task generates multi-step plan`() { - // Setup: Create agent with mock planning that returns a multi-step plan - val agent = createTestAgentWithPlanning( - perceptionResult = ::createPendingTaskIdea, - planningResult = { task, ideas -> - Plan.ForTask( - task = task, - tasks = listOf( - Task.CodeChange( - id = "step-1-${task.id}", - status = TaskStatus.Pending, - description = "Create User data class with name and email properties", - ), - Task.CodeChange( - id = "step-2-${task.id}", - status = TaskStatus.Pending, - description = "Add validation logic for email format", - ), - Task.CodeChange( - id = "step-3-${task.id}", - status = TaskStatus.Pending, - description = "Write unit tests for User class", - ), - ), - estimatedComplexity = 6, - expectations = Expectations.blank, - ) - }, - ) - - val complexTask = Task.CodeChange( - id = "task-complex", - status = TaskStatus.Pending, - description = "Implement user authentication with validation and tests", - ) - - val complexIdeas = listOf( - Idea( - name = "Complex task", - description = "This requires data model, validation, and testing", - ), - ) - - // Execute - val plan = agent.runLLMToPlan(complexTask, complexIdeas) - - // Verify - assertNotNull(plan) - assertTrue(plan is Plan.ForTask) - assertTrue(plan.tasks.size >= 3, "Complex task should have multiple steps") - assertTrue(plan.estimatedComplexity > 3, "Complex task should have higher complexity") - } - - /** - * Test: Plan steps are logically ordered - * - * Validates that plan steps are sequenced in a way that makes sense - * (e.g., write code before testing it). - */ - @Test - fun `plan steps are logically ordered with dependencies`() { - // Setup: Create agent with mock planning that returns ordered steps - val agent = createTestAgentWithPlanning( - perceptionResult = ::createPendingTaskIdea, - planningResult = { task, ideas -> - Plan.ForTask( - task = task, - tasks = listOf( - Task.CodeChange( - id = "step-1-${task.id}", - status = TaskStatus.Pending, - description = "Write the implementation", - ), - Task.CodeChange( - id = "step-2-${task.id}", - status = TaskStatus.Pending, - description = "Write tests for the implementation", - ), - ), - estimatedComplexity = 4, - expectations = Expectations.blank, - ) - }, - ) - - val task = Task.CodeChange( - id = "task-ordered", - status = TaskStatus.Pending, - description = "Implement a feature with tests", - ) - - val ideas = listOf( - Idea( - name = "Implementation strategy", - description = "Write implementation first, then tests", - ), - ) - - // Execute - val plan = agent.runLLMToPlan(task, ideas) - - // Verify - assertNotNull(plan) - assertTrue(plan is Plan.ForTask) - kotlin.test.assertEquals(2, plan.tasks.size) - - // First step should be implementation - val firstStep = plan.tasks[0] as Task.CodeChange - assertTrue( - firstStep.description.contains("implementation", ignoreCase = true) || - firstStep.description.contains("write", ignoreCase = true), - ) - - // Second step should be tests - val secondStep = plan.tasks[1] as Task.CodeChange - assertTrue(secondStep.description.contains("test", ignoreCase = true)) - } - - /** - * Test: Plan includes all necessary information - * - * Validates that each step in the plan has the required information. - */ - @Test - fun `plan steps include necessary information`() { - // Setup: Create agent with mock planning - val agent = createTestAgentWithPlanning( - perceptionResult = ::createPendingTaskIdea, - planningResult = { task, ideas -> - Plan.ForTask( - task = task, - tasks = listOf( - Task.CodeChange( - id = "step-1-${task.id}", - status = TaskStatus.Pending, - description = "Create User.kt file with data class User(name: String, email: String)", - assignedTo = AssignedTo.Agent("TestAgent"), - ), - ), - estimatedComplexity = 2, - expectations = Expectations.blank, - ) - }, - ) - - val task = Task.CodeChange( - id = "task-detailed", - status = TaskStatus.Pending, - description = "Create a user data class", - ) - - val ideas = listOf( - Idea( - name = "Implementation details", - description = "User should have name and email", - ), - ) - - // Execute - val plan = agent.runLLMToPlan(task, ideas) - - // Verify - assertNotNull(plan) - assertTrue(plan is Plan.ForTask) - - val step = plan.tasks[0] as Task.CodeChange - assertNotEquals("", step.id, "Step should have an ID") - assertNotEquals("", step.description, "Step should have a description") - assertTrue(step.description.length > 10, "Step description should be meaningful") - } - - /** - * Test: Planning handles blank task gracefully - * - * Validates that planning returns blank plan for blank task. - */ - @Test - fun `planning handles blank task gracefully`() { - // Setup: Create agent (planning logic should handle blank task internally) - val agent = createTestAgent(::createEmptyStateIdea) - - val blankTask = Task.Blank - - val ideas = emptyList() - - // Execute - val plan = agent.runLLMToPlan(blankTask, ideas) - - // Verify - assertNotNull(plan) - assertTrue(plan is Plan.Blank) - } - - /** - * Test: Planning with no ideas still generates a plan - * - * Validates that planning can work even without insights from perception. - */ - @Test - fun `planning with no ideas generates reasonable plan`() { - // Setup: Create agent with mock planning - val agent = createTestAgentWithPlanning( - perceptionResult = ::createPendingTaskIdea, - planningResult = { task, ideas -> - // Even with no ideas, should create a basic plan - Plan.ForTask( - task = task, - tasks = listOf( - Task.CodeChange( - id = "step-1-${task.id}", - status = TaskStatus.Pending, - description = "Execute task: ${(task as Task.CodeChange).description}", - ), - ), - estimatedComplexity = 3, - expectations = Expectations.blank, - ) - }, - ) - - val task = Task.CodeChange( - id = "task-no-ideas", - status = TaskStatus.Pending, - description = "Simple task", - ) - - val noIdeas = emptyList() - - // Execute - val plan = agent.runLLMToPlan(task, noIdeas) - - // Verify - assertNotNull(plan) - assertTrue(plan is Plan.ForTask) - assertTrue(plan.tasks.isNotEmpty(), "Plan should have at least one step") - } - - /** - * Test: Planning complexity estimation is reasonable - * - * Validates that estimated complexity matches the plan's actual complexity. - */ - @Test - fun `plan complexity estimation matches task complexity`() { - // Setup: Test both simple and complex plans - val simpleAgent = createTestAgentWithPlanning( - perceptionResult = ::createPendingTaskIdea, - planningResult = { task, ideas -> - Plan.ForTask( - task = task, - tasks = listOf( - Task.CodeChange( - id = "step-1-${task.id}", - status = TaskStatus.Pending, - description = "Simple step", - ), - ), - estimatedComplexity = 1, - expectations = Expectations.blank, - ) - }, - ) - - val complexAgent = createTestAgentWithPlanning( - perceptionResult = ::createPendingTaskIdea, - planningResult = { task, ideas -> - Plan.ForTask( - task = task, - tasks = listOf( - Task.CodeChange(id = "step-1", status = TaskStatus.Pending, description = "Step 1"), - Task.CodeChange(id = "step-2", status = TaskStatus.Pending, description = "Step 2"), - Task.CodeChange(id = "step-3", status = TaskStatus.Pending, description = "Step 3"), - Task.CodeChange(id = "step-4", status = TaskStatus.Pending, description = "Step 4"), - Task.CodeChange(id = "step-5", status = TaskStatus.Pending, description = "Step 5"), - ), - estimatedComplexity = 8, - expectations = Expectations.blank, - ) - }, - ) - - val simpleTask = Task.CodeChange(id = "simple", status = TaskStatus.Pending, description = "Simple") - val complexTask = Task.CodeChange(id = "complex", status = TaskStatus.Pending, description = "Complex") - - // Execute - val simplePlan = simpleAgent.runLLMToPlan(simpleTask, emptyList()) - val complexPlan = complexAgent.runLLMToPlan(complexTask, emptyList()) - - // Verify - assertTrue( - simplePlan.estimatedComplexity < complexPlan.estimatedComplexity, - "Complex plan should have higher complexity than simple plan", - ) - assertTrue(simplePlan.estimatedComplexity in 1..3, "Simple plan complexity should be low") - assertTrue(complexPlan.estimatedComplexity in 6..10, "Complex plan complexity should be high") - } - - // ==================== TASK EXECUTION TESTS ==================== - - /** - * Test: Blank task returns blank outcome - * - * Validates that executing a blank task returns a blank outcome without errors. - */ - @Test - fun `executing blank task returns blank outcome`() { - // Setup - val agent = createTestAgent(::createEmptyStateIdea) - val blankTask = Task.Blank - - // Execute - val outcome = agent.runLLMToExecuteTask(blankTask) - - // Verify - assertNotNull(outcome) - assertTrue(outcome is Outcome.Blank, "Blank task should return blank outcome") - } - - /** - * Test: Unsupported task type returns failure outcome - * - * Validates that non-CodeChange tasks return a descriptive failure. - */ - @Test - fun `executing unsupported task type returns failure outcome`() { - // Setup - val agent = createTestAgent(::createEmptyStateIdea) - val meetingTask = MeetingTask.AgendaItem( - id = "meeting-1", - status = TaskStatus.Pending, - title = "Discuss architecture", - ) - - // Execute - val outcome = agent.runLLMToExecuteTask(meetingTask) - - // Verify - assertNotNull(outcome) - assertTrue(outcome is Outcome.Failure, "Unsupported task should return failure") - } - - /** - * Test: Task execution creates execution request with proper context - * - * Validates that task execution builds the proper ExecutionRequest structure. - * This test is conceptual since we can't easily verify internal requests without mocking. - */ - @Test - fun `task execution uses executor pattern`() { - // This test validates the architectural pattern rather than specific behavior - // The implementation should: - // 1. Use executor.execute() to invoke tools - // 2. Not call tool.execute() directly - // 3. Collect Flow and extract final outcome - - // Setup - val agent = createTestAgent(::createPendingTaskIdea) - - // Verify the agent has an executor configured - assertNotNull(agent.requiredTools) - assertTrue(agent.requiredTools.isNotEmpty(), "Agent should have required tools") - } - - /** - * Test: Failed execution generates proper failure outcome - * - * Validates that when execution fails, we get a meaningful failure outcome. - * This is a conceptual test showing the expected behavior. - */ - @Test - fun `failed task execution provides clear error message`() { - // Setup: Create an agent with a task that would fail (e.g., invalid LLM response) - val agent = createTestAgent(::createEmptyStateIdea) - - // Note: In a real scenario with a working LLM integration, we would test: - // - LLM call failures produce clear error messages - // - Parsing failures include what went wrong - // - File write failures specify which file failed - - // For now, we verify the agent exists and has the required infrastructure - assertNotNull(agent.id) - assertTrue(agent.id.endsWith("CodeWriterAgent"), "Agent ID should end with 'CodeWriterAgent'") - } - - /** - * Test: Agent has executor configured - * - * Validates that the CodeWriterAgent is configured with an executor. - */ - @Test - fun `agent has executor configured for tool execution`() { - // Setup - val agent = createTestAgent(::createPendingTaskIdea) - - // Verify the agent can be created with required dependencies - assertNotNull(agent) - assertNotNull(agent.requiredTools) - - // The agent should have the write code file tool - val writeCodeTool = agent.requiredTools.firstOrNull { it.name == "Write Code File" } - assertNotNull(writeCodeTool, "Agent should have Write Code File tool configured") - } - - /** - * Test: Execution request contains proper ticket and task context - * - * Validates that execution requests are built with proper context. - * This is validated conceptually since we can't intercept internal requests. - */ - @Test - fun `execution requests contain ticket and task context`() { - // Setup - val agent = createTestAgent(::createPendingTaskIdea) - val task = Task.CodeChange( - id = "test-task", - status = TaskStatus.Pending, - description = "Create a data class for User", - ) - - // The implementation should: - // 1. Create or use an existing ticket for the task - // 2. Build ExecutionContext with ticket, task, executor ID - // 3. Pass this context to the executor - - // Verify task structure is valid for execution - assertNotNull(task.id) - assertNotNull(task.description) - assertTrue(task.description.isNotEmpty(), "Task should have description") - } - - /** - * Test: Multiple files execution stops on first failure - * - * Validates that when executing multiple file writes, execution stops - * at the first failure rather than continuing. - */ - @Test - fun `multi-file execution stops on first failure`() { - // This is a conceptual test for the expected behavior - // The implementation should: - // 1. Execute files in sequence - // 2. If file 2 of 5 fails, stop execution - // 3. Return outcomes for files 1 and 2 (success + failure) - // 4. Not attempt files 3, 4, 5 - - val agent = createTestAgent(::createPendingTaskIdea) - assertNotNull(agent) - - // In a full test with mocked executors, we would: - // - Mock executor to fail on second file - // - Verify only 2 execute() calls happen - // - Verify final outcome is failure with partial results - } - - /** - * Test: Successful execution aggregates file outcomes - * - * Validates that successful multi-file execution aggregates outcomes properly. - */ - @Test - fun `successful multi-file execution aggregates outcomes`() { - // This is a conceptual test for the expected behavior - // The implementation should: - // 1. Execute all files successfully - // 2. Aggregate ExecutionOutcomes into task Outcome - // 3. Return success outcome listing all files written - - val agent = createTestAgent(::createPendingTaskIdea) - assertNotNull(agent) - - // In a full test with mocked executors, we would: - // - Mock executor to succeed for all files - // - Verify all files are executed - // - Verify final outcome includes all file paths - } - - // ==================== OUTCOME EVALUATION TESTS ==================== - - /** - * Test: Evaluation of empty outcomes list - * - * Validates that evaluating an empty list of outcomes returns a graceful idea. - */ - @Test - fun `evaluation of empty outcomes returns graceful idea`() { - // Setup - val agent = createTestAgent(::createEmptyStateIdea) - val emptyOutcomes = emptyList() - - // Execute - val idea = agent.runLLMToEvaluateOutcomes(emptyOutcomes) - - // Verify - assertNotNull(idea) - assertNotEquals("", idea.name) - assertNotEquals("", idea.description) - assertContains(idea.name.lowercase(), "no outcomes", ignoreCase = true) - } - - /** - * Test: Evaluation of only blank outcomes - * - * Validates that evaluating only blank outcomes returns a graceful idea. - */ - @Test - fun `evaluation of blank outcomes returns graceful idea`() { - // Setup - val agent = createTestAgent(::createEmptyStateIdea) - val blankOutcomes = listOf(Outcome.Blank, Outcome.Blank, Outcome.Blank) - - // Execute - val idea = agent.runLLMToEvaluateOutcomes(blankOutcomes) - - // Verify - assertNotNull(idea) - assertNotEquals("", idea.name) - assertNotEquals("", idea.description) - assertContains(idea.name.lowercase(), "blank", ignoreCase = true) - } - - /** - * Test: Evaluation identifies success patterns - * - * Validates that the evaluation function can identify patterns in successful outcomes. - * Uses a testable agent with controlled outcome evaluation. - */ - @Test - fun `evaluation identifies success patterns in outcomes`() { - // Setup: Create agent with mock outcome evaluation - val aiConfig = FakeAIConfiguration() - val agentConfig = AgentConfiguration( - agentDefinition = WriteCodeAgent, - aiConfiguration = aiConfig, - ) - - val testAgent = object : CodeAgent( - agentConfig, - stubTool, - testScope, - ) { - override val runLLMToEvaluateOutcomes: (outcomes: List) -> Idea = { outcomes -> - // Simulate LLM identifying a success pattern - Idea( - name = "Outcome evaluation: ${outcomes.size} executions analyzed", - description = """ - Learnings from ${outcomes.size} execution outcomes (3 successful, 0 failed): - - 1. Code changes consistently succeed when using absolute file paths - - Reasoning: All successful executions used absolute paths - - Actionable Advice: Always use absolute paths for file operations - - Confidence: high - Evidence Count: 3 - """.trimIndent(), - ) - } - } - - // Create successful outcomes - val now = Clock.System.now() - val successfulOutcomes = listOf( - ExecutionOutcome.CodeChanged.Success( - executorId = "executor-1", - ticketId = "ticket-1", - taskId = "task-1", - executionStartTimestamp = now, - executionEndTimestamp = now.plus(1.seconds), - changedFiles = listOf("/absolute/path/User.kt"), - validation = ExecutionResult(codeChanges = null, compilation = null, linting = null, tests = null), - ), - ExecutionOutcome.CodeChanged.Success( - executorId = "executor-1", - ticketId = "ticket-2", - taskId = "task-2", - executionStartTimestamp = now, - executionEndTimestamp = now.plus(1.seconds), - changedFiles = listOf("/absolute/path/Order.kt"), - validation = ExecutionResult(codeChanges = null, compilation = null, linting = null, tests = null), - ), - ExecutionOutcome.CodeChanged.Success( - executorId = "executor-1", - ticketId = "ticket-3", - taskId = "task-3", - executionStartTimestamp = now, - executionEndTimestamp = now.plus(1.seconds), - changedFiles = listOf("/absolute/path/Product.kt"), - validation = ExecutionResult(codeChanges = null, compilation = null, linting = null, tests = null), - ), - ) - - // Execute - val idea = testAgent.runLLMToEvaluateOutcomes(successfulOutcomes) - - // Verify - assertNotNull(idea) - assertContains(idea.description, "3", ignoreCase = true) - assertContains(idea.description, "successful", ignoreCase = true) - assertTrue( - idea.description.contains("pattern", ignoreCase = true) || - idea.description.contains("learning", ignoreCase = true) || - idea.description.contains("advice", ignoreCase = true), - ) - } - - /** - * Test: Evaluation generates actionable advice - * - * Validates that insights include specific actionable recommendations, - * not just observations. - */ - @Test - fun `evaluation generates actionable advice from outcomes`() { - // Setup: Create agent with mock outcome evaluation focused on actionable advice - val aiConfig = FakeAIConfiguration() - val agentConfig = AgentConfiguration( - agentDefinition = WriteCodeAgent, - aiConfiguration = aiConfig, - ) - - val testAgent = object : CodeAgent( - agentConfig, - stubTool, - testScope, - ) { - override val runLLMToEvaluateOutcomes: (outcomes: List) -> Idea = { outcomes -> - Idea( - name = "Outcome evaluation: ${outcomes.size} executions analyzed", - description = """ - Learnings from ${outcomes.size} execution outcomes (1 successful, 1 failed): - - 1. Simple tasks succeed while complex tasks fail - - Reasoning: Complex tasks tend to have more dependencies and edge cases - - Actionable Advice: Break complex tasks into multiple smaller, focused tasks - - Confidence: medium - Evidence Count: 2 - """.trimIndent(), - ) - } - } - - // Create mixed outcomes - val now = Clock.System.now() - val mixedOutcomes = listOf( - ExecutionOutcome.CodeChanged.Success( - executorId = "executor-1", - ticketId = "ticket-simple", - taskId = "task-simple", - executionStartTimestamp = now, - executionEndTimestamp = now.plus(500.seconds), - changedFiles = listOf("Simple.kt"), - validation = ExecutionResult(codeChanges = null, compilation = null, linting = null, tests = null), - ), - ExecutionOutcome.CodeChanged.Failure( - executorId = "executor-1", - ticketId = "ticket-complex", - taskId = "task-complex", - executionStartTimestamp = now, - executionEndTimestamp = now.plus(3.seconds), - error = ExecutionError( - type = ExecutionError.Type.TOOL_UNAVAILABLE, - message = "Complex task failed", - ), - ), - ) - - // Execute - val idea = testAgent.runLLMToEvaluateOutcomes(mixedOutcomes) - - // Verify - should contain actionable advice, not just observations - assertNotNull(idea) - assertTrue( - idea.description.contains("advice", ignoreCase = true) || - idea.description.contains("break", ignoreCase = true) || - idea.description.contains("smaller", ignoreCase = true), - "Evaluation should include actionable advice", - ) - } - - /** - * Test: Confidence levels reflect evidence count - * - * Validates that learnings with more evidence have higher confidence. - */ - @Test - fun `evaluation confidence levels correlate with evidence count`() { - // Setup: Create agent that returns learnings with different confidence levels - val aiConfig = FakeAIConfiguration() - val agentConfig = AgentConfiguration( - agentDefinition = WriteCodeAgent, - aiConfiguration = aiConfig, - ) - - val testAgent = object : CodeAgent( - agentConfig, - stubTool, - testScope, - ) { - override val runLLMToEvaluateOutcomes: (outcomes: List) -> Idea = { outcomes -> - val evidenceCount = outcomes.count { it is Outcome.Success } - val confidence = when { - evidenceCount >= 5 -> "high" - evidenceCount >= 2 -> "medium" - else -> "low" - } - - Idea( - name = "Outcome evaluation: ${outcomes.size} executions analyzed", - description = """ - Learnings from ${outcomes.size} execution outcomes: - - 1. Pattern identified - - Confidence: $confidence - Evidence Count: $evidenceCount - """.trimIndent(), - ) - } - } - - // Test with few examples (should be low/medium confidence) - val now = Clock.System.now() - val fewOutcomes = listOf( - ExecutionOutcome.CodeChanged.Success( - executorId = "executor-1", - ticketId = "ticket-1", - taskId = "task-1", - executionStartTimestamp = now, - executionEndTimestamp = now.plus(1.seconds), - changedFiles = listOf("File1.kt"), - validation = ExecutionResult(codeChanges = null, compilation = null, linting = null, tests = null), - ), - ) - - val ideaFew = testAgent.runLLMToEvaluateOutcomes(fewOutcomes) - assertTrue( - ideaFew.description.contains("low", ignoreCase = true), - "Few examples should result in low confidence", - ) - - // Test with many examples (should be high confidence) - val manyOutcomes = (1..6).map { i -> - ExecutionOutcome.CodeChanged.Success( - executorId = "executor-1", - ticketId = "ticket-$i", - taskId = "task-$i", - executionStartTimestamp = now, - executionEndTimestamp = now.plus(1.seconds), - changedFiles = listOf("File$i.kt"), - validation = ExecutionResult(codeChanges = null, compilation = null, linting = null, tests = null), - ) - } - - val ideaMany = testAgent.runLLMToEvaluateOutcomes(manyOutcomes) - assertTrue( - ideaMany.description.contains("high", ignoreCase = true), - "Many examples should result in high confidence", - ) - } - - /** - * Test: Learnings are persisted in agent memory - * - * Validates that generated learnings are stored in the agent's past knowledge. - */ - @Test - fun `evaluation stores learnings in agent memory`() { - // Setup: Create agent with real state to verify persistence - val aiConfig = FakeAIConfiguration() - val agentConfig = AgentConfiguration( - agentDefinition = WriteCodeAgent, - aiConfiguration = aiConfig, - ) - - // Create a fresh state instance for this test (not using shared CodeState.blank) - val agentState = CodeState( - outcome = Outcome.blank, - task = Task.Blank, - plan = Plan.blank, - ) - - val testAgent = object : CodeAgent( - agentConfig, - stubTool, - testScope, - agentState, - ) { - override val runLLMToEvaluateOutcomes: (outcomes: List) -> Idea = { outcomes -> - // For this test, create knowledge manually and store it - val knowledge = Knowledge.FromOutcome( - outcomeId = outcomes.firstOrNull()?.id ?: "test-outcome", - approach = "Test pattern identified", - learnings = "Test learning stored", - timestamp = Clock.System.now(), - ) - - initialState.addToPastKnowledge( - rememberedKnowledgeFromOutcomes = listOf(knowledge), - ) - - Idea( - name = "Test evaluation", - description = "Learning stored", - ) - } - } - - // Verify no knowledge initially (fresh state should be empty) - val initialKnowledge = agentState.getPastMemory().knowledgeFromOutcomes - assertTrue(initialKnowledge.isEmpty(), "Should start with no knowledge") - - // Create outcome - val now = Clock.System.now() - val outcomes = listOf( - ExecutionOutcome.CodeChanged.Success( - executorId = "executor-1", - ticketId = "ticket-1", - taskId = "task-1", - executionStartTimestamp = now, - executionEndTimestamp = now.plus(1.seconds), - changedFiles = listOf("Test.kt"), - validation = ExecutionResult(codeChanges = null, compilation = null, linting = null, tests = null), - ), - ) - - // Execute - val idea = testAgent.runLLMToEvaluateOutcomes(outcomes) - - // Verify learnings were stored - val storedKnowledge = agentState.getPastMemory().knowledgeFromOutcomes - assertTrue(storedKnowledge.isNotEmpty(), "Learnings should be stored in agent memory") - assertEquals(1, storedKnowledge.size, "Should have one learning stored") - } - - /** - * Test: Evaluation handles mixed success and failure outcomes - * - * Validates that the evaluation function can analyze outcomes with both - * successes and failures and extract meaningful patterns. - */ - @Test - fun `evaluation handles mixed success and failure outcomes`() { - // Setup - val aiConfig = FakeAIConfiguration() - val agentConfig = AgentConfiguration( - agentDefinition = WriteCodeAgent, - aiConfiguration = aiConfig, - ) - - val testAgent = object : CodeAgent( - agentConfig, - stubTool, - testScope, - ) { - override val runLLMToEvaluateOutcomes: (outcomes: List) -> Idea = { outcomes -> - val successCount = outcomes.count { it is Outcome.Success } - val failureCount = outcomes.count { it is Outcome.Failure } - - Idea( - name = "Outcome evaluation: ${outcomes.size} executions analyzed", - description = """ - Learnings from ${outcomes.size} execution outcomes ($successCount successful, $failureCount failed): - - 1. Some tasks succeed while others fail - - Reasoning: Mixed results indicate task-dependent factors - - Actionable Advice: Analyze which task characteristics lead to success - - Confidence: medium - Evidence Count: ${outcomes.size} - """.trimIndent(), - ) - } - } - - // Create mixed outcomes - val now = Clock.System.now() - val mixedOutcomes = listOf( - ExecutionOutcome.CodeChanged.Success( - executorId = "executor-1", - ticketId = "ticket-1", - taskId = "task-1", - executionStartTimestamp = now, - executionEndTimestamp = now.plus(1.seconds), - changedFiles = listOf("Success1.kt"), - validation = ExecutionResult(codeChanges = null, compilation = null, linting = null, tests = null), - ), - ExecutionOutcome.CodeChanged.Failure( - executorId = "executor-1", - ticketId = "ticket-2", - taskId = "task-2", - executionStartTimestamp = now, - executionEndTimestamp = now.plus(2.seconds), - error = ExecutionError( - type = ExecutionError.Type.TOOL_UNAVAILABLE, - message = "Failed", - ), - ), - ExecutionOutcome.CodeChanged.Success( - executorId = "executor-1", - ticketId = "ticket-3", - taskId = "task-3", - executionStartTimestamp = now, - executionEndTimestamp = now.plus(1.seconds), - changedFiles = listOf("Success2.kt"), - validation = ExecutionResult(codeChanges = null, compilation = null, linting = null, tests = null), - ), - ) - - // Execute - val idea = testAgent.runLLMToEvaluateOutcomes(mixedOutcomes) - - // Verify - should handle mixed outcomes gracefully - assertNotNull(idea) - assertContains(idea.description, "2 successful", ignoreCase = true) - assertContains(idea.description, "1 failed", ignoreCase = true) - } - - /** - * Test: Evaluation recognizes meta-patterns - * - * Validates that the evaluation can identify higher-order patterns like - * "simple tasks succeed more than complex ones." - */ - @Test - fun `evaluation recognizes meta-patterns across tasks`() { - // Setup - val aiConfig = FakeAIConfiguration() - val agentConfig = AgentConfiguration( - agentDefinition = WriteCodeAgent, - aiConfiguration = aiConfig, - ) - - val testAgent = object : CodeAgent( - agentConfig, - stubTool, - testScope, - ) { - override val runLLMToEvaluateOutcomes: (outcomes: List) -> Idea = { outcomes -> - Idea( - name = "Outcome evaluation: ${outcomes.size} executions analyzed", - description = """ - Learnings from ${outcomes.size} execution outcomes (4 successful, 0 failed): - - 1. File writes with single files consistently succeed - - Reasoning: All successful executions involved single file changes - - Actionable Advice: When possible, structure tasks as single-file changes for higher reliability - - Confidence: high - Evidence Count: 4 - - 2. Tasks complete faster when file paths are short - - Reasoning: Meta-pattern shows shorter paths correlate with faster execution - - Actionable Advice: Prefer flat directory structures where appropriate - - Confidence: medium - Evidence Count: 4 - """.trimIndent(), - ) - } - } - - // Create outcomes showing meta-pattern (all single-file, all succeed) - val now = Clock.System.now() - val patternedOutcomes = (1..4).map { i -> - ExecutionOutcome.CodeChanged.Success( - executorId = "executor-1", - ticketId = "ticket-$i", - taskId = "task-$i", - executionStartTimestamp = now, - executionEndTimestamp = now.plus(i.seconds), - changedFiles = listOf("File$i.kt"), - validation = ExecutionResult(codeChanges = null, compilation = null, linting = null, tests = null), - ) - } - - // Execute - val idea = testAgent.runLLMToEvaluateOutcomes(patternedOutcomes) - - // Verify - should identify meta-patterns - assertNotNull(idea) - assertTrue( - idea.description.contains("pattern", ignoreCase = true) || - idea.description.contains("consistently", ignoreCase = true) || - idea.description.contains("correlate", ignoreCase = true), - "Should identify meta-patterns", - ) - } - - /** - * Test: Fallback evaluation when analysis fails - * - * Validates that the evaluation provides basic statistics when - * advanced analysis fails (e.g., LLM error, parsing error). - */ - @Test - fun `evaluation provides fallback statistics when analysis fails`() { - // Setup: Agent that simulates a fallback scenario - val aiConfig = FakeAIConfiguration() - val agentConfig = AgentConfiguration( - agentDefinition = WriteCodeAgent, - aiConfiguration = aiConfig, - ) - - val agentState = CodeState.blank - - val testAgent = object : CodeAgent( - agentConfig, - stubTool, - testScope, - agentState, - ) { - override val runLLMToEvaluateOutcomes: (outcomes: List) -> Idea = { outcomes -> - // Simulate fallback when LLM analysis fails - val successCount = outcomes.count { it is Outcome.Success } - val failCount = outcomes.count { it is Outcome.Failure } - - Idea( - name = "Outcome evaluation (basic statistics)", - description = buildString { - appendLine("Basic outcome statistics (advanced analysis unavailable - Test: LLM call failed):") - appendLine() - appendLine("Total Outcomes: ${outcomes.size}") - appendLine("Successful: $successCount") - appendLine("Failed: $failCount") - }, - ) - } - } - - // Create outcomes - val now = Clock.System.now() - val outcomes = listOf( - ExecutionOutcome.CodeChanged.Success( - executorId = "executor-1", - ticketId = "ticket-1", - taskId = "task-1", - executionStartTimestamp = now, - executionEndTimestamp = now.plus(1.seconds), - changedFiles = listOf("File1.kt"), - validation = ExecutionResult(codeChanges = null, compilation = null, linting = null, tests = null), - ), - ExecutionOutcome.CodeChanged.Success( - executorId = "executor-1", - ticketId = "ticket-2", - taskId = "task-2", - executionStartTimestamp = now, - executionEndTimestamp = now.plus(1.seconds), - changedFiles = listOf("File2.kt"), - validation = ExecutionResult(codeChanges = null, compilation = null, linting = null, tests = null), - ), - ) - - // Execute - val idea = testAgent.runLLMToEvaluateOutcomes(outcomes) - - // Verify - should have basic statistics - assertNotNull(idea) - assertContains(idea.description, "2", ignoreCase = true) - assertContains(idea.description, "successful", ignoreCase = true) - assertTrue( - idea.description.contains("basic", ignoreCase = true) || - idea.description.contains("statistics", ignoreCase = true) || - idea.description.contains("fallback", ignoreCase = true) || - idea.description.contains("unavailable", ignoreCase = true), - "Fallback idea should mention limited analysis", - ) - - // Verify fallback knowledge was still stored - val storedKnowledge = agentState.getPastMemory().knowledgeFromOutcomes - assertTrue(storedKnowledge.isNotEmpty(), "Fallback should still store basic knowledge") - } - - /** - * Test: High failure rate triggers warning in evaluation - * - * Validates that when failure rate is high, the evaluation - * includes warnings and suggests remedial actions. - */ - @Test - fun `evaluation warns about high failure rate`() { - // Setup - val aiConfig = FakeAIConfiguration() - val agentConfig = AgentConfiguration( - agentDefinition = WriteCodeAgent, - aiConfiguration = aiConfig, - ) - - val testAgent = object : CodeAgent( - agentConfig, - stubTool, - testScope, - ) { - override val runLLMToEvaluateOutcomes: (outcomes: List) -> Idea = { outcomes -> - val successCount = outcomes.count { it is Outcome.Success } - val failureCount = outcomes.count { it is Outcome.Failure } - - Idea( - name = "Outcome evaluation: ${outcomes.size} executions analyzed", - description = """ - Learnings from ${outcomes.size} execution outcomes ($successCount successful, $failureCount failed): - - ⚠ High failure rate detected (67%) - - 1. Tasks are consistently failing - - Reasoning: Only 1 of 3 tasks succeeded - - Actionable Advice: Break tasks into smaller steps or review task specifications - - Confidence: high - Evidence Count: 3 - """.trimIndent(), - ) - } - } - - // Create outcomes with high failure rate - val now = Clock.System.now() - val highFailureOutcomes = listOf( - ExecutionOutcome.CodeChanged.Success( - executorId = "executor-1", - ticketId = "ticket-1", - taskId = "task-1", - executionStartTimestamp = now, - executionEndTimestamp = now.plus(1.seconds), - changedFiles = listOf("Success.kt"), - validation = ExecutionResult(codeChanges = null, compilation = null, linting = null, tests = null), - ), - ExecutionOutcome.CodeChanged.Failure( - executorId = "executor-1", - ticketId = "ticket-2", - taskId = "task-2", - executionStartTimestamp = now, - executionEndTimestamp = now.plus(1.seconds), - error = ExecutionError( - type = ExecutionError.Type.TOOL_UNAVAILABLE, - message = "Failed 1", - ), - ), - ExecutionOutcome.CodeChanged.Failure( - executorId = "executor-1", - ticketId = "ticket-3", - taskId = "task-3", - executionStartTimestamp = now, - executionEndTimestamp = now.plus(1.seconds), - error = ExecutionError( - type = ExecutionError.Type.TOOL_UNAVAILABLE, - message = "Failed 2", - ), - ), - ) - - // Execute - val idea = testAgent.runLLMToEvaluateOutcomes(highFailureOutcomes) - - // Verify - should warn about high failure rate - assertNotNull(idea) - assertTrue( - idea.description.contains("⚠", ignoreCase = false) || - idea.description.contains("high", ignoreCase = true) || - idea.description.contains("failure", ignoreCase = true) || - idea.description.contains("warning", ignoreCase = true), - "Should warn about high failure rate", - ) - } -} diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/implementations/ProjectManagerAgentTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/implementations/ProjectManagerAgentTest.kt deleted file mode 100644 index 9c620152..00000000 --- a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/implementations/ProjectManagerAgentTest.kt +++ /dev/null @@ -1,334 +0,0 @@ -package link.socket.ampere.agents.implementations - -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.datetime.Clock -import link.socket.ampere.agents.config.AgentActionAutonomy -import link.socket.ampere.agents.config.AgentConfiguration -import link.socket.ampere.agents.definition.ProjectAgent -import link.socket.ampere.agents.definition.project.Goal -import link.socket.ampere.agents.definition.project.ProjectState -import link.socket.ampere.agents.domain.error.ExecutionError -import link.socket.ampere.agents.domain.knowledge.Knowledge -import link.socket.ampere.agents.domain.outcome.ExecutionOutcome -import link.socket.ampere.agents.domain.outcome.Outcome -import link.socket.ampere.agents.domain.reasoning.AgentReasoning -import link.socket.ampere.agents.domain.reasoning.EvaluationResult -import link.socket.ampere.agents.domain.reasoning.Idea -import link.socket.ampere.agents.domain.reasoning.Plan -import link.socket.ampere.agents.domain.status.TaskStatus -import link.socket.ampere.agents.domain.task.Task -import link.socket.ampere.agents.execution.request.ExecutionContext -import link.socket.ampere.agents.execution.tools.Tool -import link.socket.ampere.agents.execution.tools.ToolAskHuman -import link.socket.ampere.agents.execution.tools.ToolCreateIssues -import link.socket.ampere.agents.execution.tools.issue.BatchIssueCreateResponse -import link.socket.ampere.agents.execution.tools.issue.CreatedIssue -import link.socket.ampere.domain.agent.bundled.WriteCodeAgent -import link.socket.ampere.domain.ai.configuration.AIConfiguration -import link.socket.ampere.domain.ai.model.AIModel -import link.socket.ampere.domain.ai.model.AIModel_OpenAI -import link.socket.ampere.domain.ai.provider.AIProvider - -/** - * Tests for ProjectManagerAgent scaffold. - * - * These tests verify basic functionality like instantiation and knowledge extraction. - * Full integration tests will be added as the agent implementation matures. - */ -@OptIn(ExperimentalCoroutinesApi::class) -class ProjectManagerAgentTest { - - private lateinit var projectAgent: ProjectAgent - private val testScope = TestScope(UnconfinedTestDispatcher()) - - private val toolCreateIssues: Tool = ToolCreateIssues( - requiredAgentAutonomy = AgentActionAutonomy.ACT_WITH_NOTIFICATION, - ) - - private val toolAskHuman: Tool = ToolAskHuman( - requiredAgentAutonomy = AgentActionAutonomy.ASK_BEFORE_ACTION, - ) - - /** - * Fake AI configuration for testing. - */ - private class FakeAIConfiguration : AIConfiguration { - override val provider: AIProvider<*, *> - get() = throw NotImplementedError("Provider not needed for these tests") - override val model: AIModel - get() = AIModel_OpenAI.GPT_4_1 - - override fun getAvailableModels(): List, AIModel>> = emptyList() - } - - @BeforeTest - fun setup() { - val agentConfiguration = AgentConfiguration( - agentDefinition = WriteCodeAgent, // Placeholder - PM agent definition not yet created - aiConfiguration = FakeAIConfiguration(), - ) - - // Create mock reasoning for testing without real LLM calls - val mockReasoning = AgentReasoning.createForTesting("test-executor") { - onPerception { perception -> - Idea( - name = "Project Manager Perception", - description = "Mock perception analysis for testing", - ) - } - - onPlanning { task, ideas -> - Plan.ForTask( - task = task, - tasks = listOf(task), - estimatedComplexity = 1, - ) - } - - onOutcomeEvaluation { outcomes -> - EvaluationResult( - summaryIdea = Idea( - name = "Mock outcome evaluation", - description = "Task completed successfully", - ), - knowledge = emptyList(), - ) - } - - onLLMCall { prompt -> - "Mock LLM response" - } - } - - projectAgent = ProjectAgent( - agentConfiguration = agentConfiguration, - toolCreateIssues = toolCreateIssues, - toolAskHuman = toolAskHuman, - coroutineScope = testScope, - reasoningOverride = mockReasoning, - ) - } - - @Test - fun `agent can be instantiated`() { - // Verify the agent was created successfully - assertTrue(projectAgent.id.isNotEmpty(), "Agent ID should not be empty") - assertTrue( - projectAgent.requiredTools.contains(toolCreateIssues), - "Required tools should include ToolCreateIssues", - ) - assertTrue( - projectAgent.requiredTools.contains(toolAskHuman), - "Required tools should include ToolAskHuman", - ) - } - - @Test - fun `extractKnowledgeFromOutcome captures issue creation success`() { - val task = Task.CodeChange( - id = "pm-task-1", - status = TaskStatus.Pending, - description = "Decompose feature into tasks", - ) - - val plan = Plan.ForTask( - task = task, - estimatedComplexity = 5, - ) - - val outcome = ExecutionOutcome.IssueManagement.Success( - executorId = "ProjectManagerAgent", - ticketId = "epic-123", - taskId = task.id, - executionStartTimestamp = Clock.System.now(), - executionEndTimestamp = Clock.System.now(), - response = BatchIssueCreateResponse( - success = true, - created = listOf( - CreatedIssue( - localId = "epic-1", - issueNumber = 100, - url = "https://github.com/repo/issues/100", - ), - CreatedIssue( - localId = "task-1", - issueNumber = 101, - url = "https://github.com/repo/issues/101", - parentIssueNumber = 100, - ), - ), - errors = emptyList(), - ), - ) - - val knowledge = projectAgent.extractKnowledgeFromOutcome(outcome, task, plan) - - val fromOutcome = assertIs(knowledge, "Knowledge should be FromOutcome type") - assertTrue(fromOutcome.approach.isNotEmpty(), "Approach should be captured") - assertTrue(fromOutcome.learnings.contains("succeeded"), "Learnings should mention success") - assertTrue(fromOutcome.learnings.contains("2"), "Learnings should mention number of issues created") - assertTrue(fromOutcome.outcomeId == outcome.id, "Knowledge should reference the correct outcome ID") - } - - @Test - fun `extractKnowledgeFromOutcome captures issue creation failure`() { - val task = Task.CodeChange( - id = "pm-task-2", - status = TaskStatus.Pending, - description = "Create milestone issues", - ) - - val plan = Plan.ForTask( - task = task, - estimatedComplexity = 3, - ) - - val outcome = ExecutionOutcome.IssueManagement.Failure( - executorId = "ProjectManagerAgent", - ticketId = "epic-456", - taskId = task.id, - executionStartTimestamp = Clock.System.now(), - executionEndTimestamp = Clock.System.now(), - error = ExecutionError( - type = ExecutionError.Type.UNEXPECTED, - message = "GitHub API rate limit exceeded", - isRetryable = true, - ), - partialResponse = null, - ) - - val knowledge = projectAgent.extractKnowledgeFromOutcome(outcome, task, plan) - - val fromOutcome = assertIs(knowledge, "Knowledge should be FromOutcome type") - assertTrue(fromOutcome.approach.isNotEmpty(), "Approach should be captured") - assertTrue(fromOutcome.learnings.contains("failed"), "Learnings should mention failure") - assertTrue(fromOutcome.learnings.contains("rate limit"), "Learnings should mention the error") - } - - @Test - fun `extractKnowledgeFromOutcome captures human escalation success`() { - val task = Task.CodeChange( - id = "pm-task-3", - status = TaskStatus.Pending, - description = "Escalate scope decision", - ) - - val plan = Plan.ForTask( - task = task, - estimatedComplexity = 1, - ) - - val outcome = ExecutionOutcome.NoChanges.Success( - executorId = "ProjectManagerAgent", - ticketId = "", - taskId = task.id, - executionStartTimestamp = Clock.System.now(), - executionEndTimestamp = Clock.System.now(), - message = "Human decided to include authentication in MVP scope", - ) - - val knowledge = projectAgent.extractKnowledgeFromOutcome(outcome, task, plan) - - val fromOutcome = assertIs(knowledge, "Knowledge should be FromOutcome type") - assertTrue(fromOutcome.approach.isNotEmpty(), "Approach should be captured") - // Learnings should contain the outcome message or indicate success - assertTrue( - fromOutcome.learnings.contains("SUCCEEDED") || - fromOutcome.learnings.contains("authentication") || - fromOutcome.learnings.contains("Human"), - "Learnings should capture the outcome: ${fromOutcome.learnings}", - ) - } - - @Test - fun `perceiveState gathers project context`() = kotlinx.coroutines.runBlocking { - val currentState = ProjectState.blank - - val perception = projectAgent.perceiveState(currentState) - - assertTrue(perception.ideas.isNotEmpty(), "Perception should contain ideas") - assertTrue( - perception.ideas.any { it.name.contains("Project Manager Perception") }, - "Should have main perception idea", - ) - assertTrue( - perception.currentState is ProjectState, - "State should be ProjectState", - ) - } - - @Test - fun `perceiveState creates idea for project status`() = kotlinx.coroutines.runBlocking { - val currentState = ProjectState.blank - - val perception = projectAgent.perceiveState(currentState) - - // With mock reasoning, we get a "Project Manager Perception" idea - val perceptionIdea = assertNotNull( - perception.ideas.find { it.name.contains("Project Manager") }, - "Should have perception idea from mock", - ) - assertTrue(perceptionIdea.description.isNotEmpty(), "Idea should have description") - } - - @Test - fun `perceiveState incorporates new ideas passed in`() = kotlinx.coroutines.runBlocking { - val currentState = ProjectState.blank - val customIdea1 = Idea( - name = "New feature request", - description = "User requested dark mode", - ) - val customIdea2 = Idea( - name = "Bug report", - description = "Login fails on mobile", - ) - - val perception = projectAgent.perceiveState(currentState, customIdea1, customIdea2) - - // perceiveState processes ideas through LLM, so the output ideas come from mock reasoning - // The important thing is that perception was created successfully - assertTrue(perception.ideas.isNotEmpty(), "Perception should have ideas") - assertTrue( - perception.currentState is ProjectState, - "Should have correct state type", - ) - } - - @Test - fun `perceiveState updates state with fresh context`() = kotlinx.coroutines.runBlocking { - val oldState = ProjectState( - outcome = Outcome.blank, - task = Task.Blank, - plan = Plan.blank, - activeGoals = listOf( - Goal("old-goal", "Old Goal", "high", "in_progress"), - ), - blockedTasks = listOf("old-blocked-task"), - ) - - val perception = projectAgent.perceiveState(oldState) - - val newState = perception.currentState as ProjectState - // With stub implementations, these will be empty - // But we verify the state was refreshed (not the same as old state) - assertTrue(newState.outcome == oldState.outcome, "Outcome preserved") - assertTrue(newState.task == oldState.task, "Task preserved") - assertTrue(newState.plan == oldState.plan, "Plan preserved") - } - - @Test - fun `perceiveState creates timestamp`() = kotlinx.coroutines.runBlocking { - val currentState = ProjectState.blank - - val perception = projectAgent.perceiveState(currentState) - - assertTrue(perception.timestamp > kotlinx.datetime.Instant.DISTANT_PAST, "Should have valid timestamp") - } -} diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/implementations/QualityAssuranceAgentTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/implementations/QualityAssuranceAgentTest.kt deleted file mode 100644 index 1bca22d4..00000000 --- a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/implementations/QualityAssuranceAgentTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -package link.socket.ampere.agents.implementations - -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertIs -import kotlin.test.assertTrue -import kotlinx.coroutines.runBlocking -import kotlinx.datetime.Clock -import link.socket.ampere.agents.definition.QualityAgent -import link.socket.ampere.agents.domain.knowledge.Knowledge -import link.socket.ampere.agents.domain.memory.KnowledgeWithScore -import link.socket.ampere.agents.domain.reasoning.Plan -import link.socket.ampere.agents.domain.status.TaskStatus -import link.socket.ampere.agents.domain.task.Task -import link.socket.ampere.stubKnowledgeEntry -import link.socket.ampere.stubQualityAssuranceAgent -import link.socket.ampere.stubSuccessOutcome - -class QualityAssuranceAgentTest { - - private lateinit var qualityAgent: QualityAgent - - @BeforeTest - fun setUp() { - qualityAgent = stubQualityAssuranceAgent() - } - - @Test - fun `determinePlanForTask prioritizes effective checks`() = runBlocking { - val validationKnowledge = listOf( - KnowledgeWithScore( - entry = stubKnowledgeEntry( - id = "k1", - approach = "Performed syntax validation with high effectiveness", - learnings = "Syntax validation caught compilation errors early", - outcomeId = "outcome-1", - tags = listOf("success", "syntax"), - taskType = "code_change", - ), - knowledge = Knowledge.FromOutcome( - outcomeId = "outcome-1", - approach = "Performed syntax validation with high effectiveness", - learnings = "Syntax validation caught compilation errors early", - timestamp = Clock.System.now(), - ), - relevanceScore = 0.95, - ), - ) - - val task = Task.CodeChange( - id = "task-1", - status = TaskStatus.Pending, - description = "Validate user input handling", - ) - - val plan = qualityAgent.determinePlanForTask(task, relevantKnowledge = validationKnowledge) - - assertTrue(plan.tasks.isNotEmpty(), "Plan should include validation tasks") - } - - @Test - fun `extractKnowledgeFromOutcome captures validation success`() { - val task = Task.CodeChange( - id = "task-4", - status = TaskStatus.Pending, - description = "Validate payment processing", - ) - - val plan = Plan.ForTask(task = task) - val outcome = stubSuccessOutcome() - - val knowledge = qualityAgent.extractKnowledgeFromOutcome(outcome, task, plan) - - val fromOutcome = assertIs(knowledge) - assertTrue(fromOutcome.approach.isNotEmpty(), "Approach should be captured") - assertTrue(fromOutcome.learnings.contains("Success"), "Learnings should mention success") - } - - @Test - fun `determinePlanForTask uses default strategy without knowledge`() = runBlocking { - val task = Task.CodeChange( - id = "task-6", - status = TaskStatus.Pending, - description = "Validate new feature", - ) - - val plan = qualityAgent.determinePlanForTask(task, relevantKnowledge = emptyList()) - - assertTrue(plan.tasks.isNotEmpty(), "Plan should include default validation tasks") - } -} diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/domain/arc/AmpereRuntimeTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/domain/arc/AmpereRuntimeTest.kt index 621b7b9e..c6fc7855 100644 --- a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/domain/arc/AmpereRuntimeTest.kt +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/domain/arc/AmpereRuntimeTest.kt @@ -277,7 +277,7 @@ class AmpereRuntimeTest { assertEquals(3, result.chargeResult.agents.size) assertTrue( - result.chargeResult.agents.filterIsInstance().any { + result.chargeResult.agents.filterIsInstance>().any { it.cognitiveState.contains("Role:Operations") }, )