From 6545390f8b5e064cf0ebf6ac3075edcb8741872b Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Sun, 17 May 2026 17:09:19 -0500 Subject: [PATCH] AMPR-165 #489: JSON frontmatter for .spark.md + role spark registry I migrated the seven bundled `.spark.md` fixtures from YAML frontmatter to a JSON-fenced (`---json` / `---`) format decoded against a sealed `SparkFrontmatter` schema with `"phase"` and `"role"` variants. Added a new `role-code.spark.md` fixture and a public `SparkRegistry` surface so the `SparkBasedAgent.{Code,Quality}` factories resolve the role spark from the declarative library by canonical id, failing fast when the fixture is missing instead of falling back to the `RoleSpark.Code` Kotlin singleton. The legacy YAML parser is removed; unmigrated documents now surface `SparkParseError.DeprecatedYamlFrontmatter`. Closes #489 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../files/sparks/code-agent.spark.md | 17 +- .../files/sparks/cooking-domain.spark.md | 17 +- .../files/sparks/minimal-edge.spark.md | 11 +- .../files/sparks/product-agent.spark.md | 17 +- .../files/sparks/project-agent.spark.md | 17 +- .../files/sparks/quality-agent.spark.md | 17 +- .../files/sparks/recipe-arc-task.spark.md | 15 +- .../files/sparks/role-code.spark.md | 68 ++++ .../ampere/agents/definition/AgentFactory.kt | 2 + .../agents/definition/SparkBasedAgent.kt | 60 ++- .../cognition/sparks/DeclarativeRoleSpark.kt | 56 +++ .../sparks/DeclarativeSparkSource.kt | 63 +++ .../sparks/DefaultPhaseSparkLibrary.kt | 40 +- .../cognition/sparks/PhaseSparkLibrary.kt | 22 +- .../cognition/sparks/SparkFrontmatter.kt | 89 +++++ .../domain/cognition/sparks/SparkParser.kt | 218 ++++++---- .../domain/cognition/sparks/SparkRegistry.kt | 26 ++ .../kotlin/link/socket/ampere/TestHelpers.kt | 9 - .../cognition/sparks/SparkParserJsonTest.kt | 377 ++++++++++++++++++ .../cognition/sparks/SparkParserTest.kt | 182 --------- .../SparkBasedAgentCodeFactoryTest.kt | 38 +- .../SparkBasedAgentStepRoutingTest.kt | 8 + .../ampere/agents/demo/CognitiveCycleDemo.kt | 2 + .../sparks/DeclarativeRoleSparkTest.kt | 98 +++++ .../sparks/JsonMigrationEquivalenceTest.kt | 81 ++++ .../cognition/sparks/PhaseSparkLibraryTest.kt | 36 +- .../MeetingParticipationHandlerTest.kt | 2 + docs/concepts/spark-system.md | 72 +++- 28 files changed, 1276 insertions(+), 384 deletions(-) create mode 100644 ampere-core/src/commonMain/composeResources/files/sparks/role-code.spark.md create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeRoleSpark.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeSparkSource.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkFrontmatter.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkRegistry.kt create mode 100644 ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkParserJsonTest.kt delete mode 100644 ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkParserTest.kt create mode 100644 ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeRoleSparkTest.kt create mode 100644 ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/JsonMigrationEquivalenceTest.kt 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 index 56f23189..a457167b 100644 --- a/ampere-core/src/commonMain/composeResources/files/sparks/code-agent.spark.md +++ b/ampere-core/src/commonMain/composeResources/files/sparks/code-agent.spark.md @@ -1,10 +1,13 @@ ---- -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 +---json +{ + "type": "phase", + "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: diff --git a/ampere-core/src/commonMain/composeResources/files/sparks/cooking-domain.spark.md b/ampere-core/src/commonMain/composeResources/files/sparks/cooking-domain.spark.md index 3ba5d2be..cb42dce9 100644 --- a/ampere-core/src/commonMain/composeResources/files/sparks/cooking-domain.spark.md +++ b/ampere-core/src/commonMain/composeResources/files/sparks/cooking-domain.spark.md @@ -1,10 +1,13 @@ ---- -id: cooking-domain -name: Cooking Domain -whenToUse: tasks that mention recipes, ingredients, meal planning, cooking techniques, or kitchen workflows -phases: PLAN, EXECUTE -tags: cooking, recipes, food, kitchen -modelPreference: gpt-4o-mini +---json +{ + "type": "phase", + "id": "cooking-domain", + "name": "Cooking Domain", + "whenToUse": "tasks that mention recipes, ingredients, meal planning, cooking techniques, or kitchen workflows", + "phases": ["PLAN", "EXECUTE"], + "tags": ["cooking", "recipes", "food", "kitchen"], + "modelPreference": "gpt-4o-mini" +} --- ## Domain Context: Cooking diff --git a/ampere-core/src/commonMain/composeResources/files/sparks/minimal-edge.spark.md b/ampere-core/src/commonMain/composeResources/files/sparks/minimal-edge.spark.md index e0e99efa..a84eada6 100644 --- a/ampere-core/src/commonMain/composeResources/files/sparks/minimal-edge.spark.md +++ b/ampere-core/src/commonMain/composeResources/files/sparks/minimal-edge.spark.md @@ -1,7 +1,10 @@ ---- -id: minimal-edge -name: Minimal Edge -whenToUse: smoke-test fixture exercising the minimum viable declarative spark shape +---json +{ + "type": "phase", + "id": "minimal-edge", + "name": "Minimal Edge", + "whenToUse": "smoke-test fixture exercising the minimum viable declarative spark shape" +} --- ## Minimal Edge Spark 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 index fcc0d5c2..8b09a9f6 100644 --- a/ampere-core/src/commonMain/composeResources/files/sparks/product-agent.spark.md +++ b/ampere-core/src/commonMain/composeResources/files/sparks/product-agent.spark.md @@ -1,10 +1,13 @@ ---- -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 +---json +{ + "type": "phase", + "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: 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 index bbe94d2c..6893f41d 100644 --- a/ampere-core/src/commonMain/composeResources/files/sparks/project-agent.spark.md +++ b/ampere-core/src/commonMain/composeResources/files/sparks/project-agent.spark.md @@ -1,10 +1,13 @@ ---- -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 +---json +{ + "type": "phase", + "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: 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 index 9288f654..d3c61a75 100644 --- a/ampere-core/src/commonMain/composeResources/files/sparks/quality-agent.spark.md +++ b/ampere-core/src/commonMain/composeResources/files/sparks/quality-agent.spark.md @@ -1,10 +1,13 @@ ---- -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 +---json +{ + "type": "phase", + "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: diff --git a/ampere-core/src/commonMain/composeResources/files/sparks/recipe-arc-task.spark.md b/ampere-core/src/commonMain/composeResources/files/sparks/recipe-arc-task.spark.md index 686225f5..2dcfde35 100644 --- a/ampere-core/src/commonMain/composeResources/files/sparks/recipe-arc-task.spark.md +++ b/ampere-core/src/commonMain/composeResources/files/sparks/recipe-arc-task.spark.md @@ -1,9 +1,12 @@ ---- -id: recipe-arc-task -name: Recipe Arc Task -whenToUse: arc-task work that produces a multi-step recipe, meal plan, or cooking procedure as its primary artifact -phases: PLAN -tags: arc, recipe, planning, cooking +---json +{ + "type": "phase", + "id": "recipe-arc-task", + "name": "Recipe Arc Task", + "whenToUse": "arc-task work that produces a multi-step recipe, meal plan, or cooking procedure as its primary artifact", + "phases": ["PLAN"], + "tags": ["arc", "recipe", "planning", "cooking"] +} --- ## Arc Task: Recipe Production diff --git a/ampere-core/src/commonMain/composeResources/files/sparks/role-code.spark.md b/ampere-core/src/commonMain/composeResources/files/sparks/role-code.spark.md new file mode 100644 index 00000000..b401ce61 --- /dev/null +++ b/ampere-core/src/commonMain/composeResources/files/sparks/role-code.spark.md @@ -0,0 +1,68 @@ +---json +{ + "type": "role", + "id": "code", + "name": "Role:Code", + "agentRole": "Code Writer", + "requestedToolIds": [ + "read_code_file", + "write_code_file", + "run_command", + "ask_human", + "search_codebase" + ], + "allowedTools": [ + "read_code_file", + "write_code_file", + "run_command", + "ask_human", + "search_codebase" + ], + "fileAccessScope": { + "read": ["**/*"], + "write": [ + "**/*.kt", + "**/*.kts", + "**/*.java", + "**/*.xml", + "**/*.json", + "**/*.yaml", + "**/*.yml", + "**/*.properties", + "**/*.md", + "**/*.txt" + ], + "forbidden": [ + "**/build/**", + "**/.gradle/**", + "**/node_modules/**", + "**/.git/**", + "**/.env", + "**/.env.*", + "**/credentials.json", + "**/secrets.json", + "**/*.pem", + "**/*.key", + "**/id_rsa*", + "**/.git/config" + ] + } +} +--- + +## Role: Code + +You are operating in a **code-focused** capacity. Your primary responsibilities are: + +- Reading and understanding existing code +- Writing new code and modifying existing implementations +- Reviewing code for correctness, style, and potential issues +- Running commands to build, test, and verify changes + +### Guidelines + +- Follow existing code patterns and conventions in the project +- Write clear, maintainable code with appropriate comments +- Consider edge cases and error handling +- Prefer small, focused changes over large refactors +- Test changes before considering them complete 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 755a2d49..943c7194 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 @@ -204,6 +204,7 @@ class AgentFactory( val eventApi = eventApiFactory?.invoke(agentId) val memoryService = memoryServiceFactory?.invoke(agentId) SparkBasedAgent.Code( + sparkRegistry = phaseSparkLibrary, agentId = agentId, aiConfiguration = effectiveAiConfiguration, eventApi = eventApi, @@ -254,6 +255,7 @@ class AgentFactory( val eventApi = eventApiFactory?.invoke(agentId) val memoryService = memoryServiceFactory?.invoke(agentId) SparkBasedAgent.Quality( + sparkRegistry = phaseSparkLibrary, agentId = agentId, aiConfiguration = effectiveAiConfiguration, eventApi = eventApi, 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 0d47417d..151c8205 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 @@ -14,6 +14,7 @@ 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.cognition.sparks.SparkRegistry import link.socket.ampere.agents.domain.knowledge.Knowledge import link.socket.ampere.agents.domain.memory.AgentMemoryService import link.socket.ampere.agents.domain.outcome.ExecutionOutcome @@ -307,6 +308,14 @@ open class SparkBasedAgent( companion object { + /** + * Canonical id of the bundled role spark fixture + * (`role-code.spark.md`) that supplies the Code agent's role-level + * guidance and capability constraints. Looked up against the + * [PhaseSparkLibrary] handed to the `Code` / `Quality` factories. + */ + const val ROLE_CODE_SPARK_ID: String = "code" + /** * Resource id of the bundled declarative spark that supplies the * Code agent's per-phase guidance. Activated during phase entry @@ -317,7 +326,7 @@ open class SparkBasedAgent( /** * Builds a Code-focused [SparkBasedAgent]: `ANALYTICAL` affinity, - * the [RoleSpark.Code] role spark stacked at construction time, + * the declarative `role-code` 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 @@ -325,21 +334,27 @@ open class SparkBasedAgent( * 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. + * Since AMPR-165 the role spark is resolved from + * [phaseSparkLibrary] by canonical id ([ROLE_CODE_SPARK_ID]) + * rather than referenced as a compile-time singleton. Construction + * fails fast if the library has no matching fixture — there is no + * silent fallback to the old `RoleSpark.Code` object. + * + * The declarative `code-agent.spark.md` per-phase guidance is + * **not** applied here. That is the responsibility of the + * surrounding `AgentFactory` (or test harness), which wires the + * same `PhaseSparkLibrary` via the agent's internal setter before + * the first cognitive phase entry. * * @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. + * @param phaseSparkLibrary library that must contain the + * `role-code` fixture; construction fails fast otherwise. */ fun Code( + sparkRegistry: SparkRegistry, agentId: AgentId = generateUUID("SparkBasedAgent-Code"), aiConfiguration: AIConfiguration? = null, eventApi: AgentEventApi? = null, @@ -349,6 +364,7 @@ open class SparkBasedAgent( tools: Set> = emptySet(), reasoningOverride: AgentReasoning? = null, ): SparkBasedAgent { + val roleSpark = resolveCodeRoleSpark(sparkRegistry) val agent = SparkBasedAgent( agentId = agentId, cognitiveAffinity = CognitiveAffinity.ANALYTICAL, @@ -361,10 +377,20 @@ open class SparkBasedAgent( _observabilityScope = observabilityScope, _reasoningOverride = reasoningOverride, ) - agent.spark>(RoleSpark.Code) + agent.spark>(roleSpark) return agent } + private fun resolveCodeRoleSpark(registry: SparkRegistry) = + registry.roleSparkById(ROLE_CODE_SPARK_ID) + ?: error( + "SparkBasedAgent factory requires the declarative role spark " + + "'$ROLE_CODE_SPARK_ID' (from files/sparks/role-code.spark.md) " + + "in the provided SparkRegistry, but lookup returned null. " + + "Use DefaultPhaseSparkLibrary.load() so the bundled role-code " + + "fixture is included.", + ) + /** * Resource id of the bundled declarative spark that supplies the * Product agent's per-phase guidance. @@ -456,13 +482,16 @@ open class SparkBasedAgent( /** * 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. + * affinity, the declarative `role-code` spark stacked at + * construction time (validation work reads & runs code), and the + * `plan_steps` tool already in its toolset. * - * Mirrors the legacy `QualityAgent` shape. + * Mirrors the legacy `QualityAgent` shape. Per AMPR-165, the role + * spark is resolved from [phaseSparkLibrary] by canonical id; + * construction fails fast if it isn't present. */ fun Quality( + sparkRegistry: SparkRegistry, agentId: AgentId = generateUUID("SparkBasedAgent-Quality"), aiConfiguration: AIConfiguration? = null, eventApi: AgentEventApi? = null, @@ -472,6 +501,7 @@ open class SparkBasedAgent( tools: Set> = emptySet(), reasoningOverride: AgentReasoning? = null, ): SparkBasedAgent { + val roleSpark = resolveCodeRoleSpark(sparkRegistry) val agent = SparkBasedAgent( agentId = agentId, cognitiveAffinity = CognitiveAffinity.ANALYTICAL, @@ -484,7 +514,7 @@ open class SparkBasedAgent( _observabilityScope = observabilityScope, _reasoningOverride = reasoningOverride, ) - agent.spark>(RoleSpark.Code) + agent.spark>(roleSpark) return agent } } diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeRoleSpark.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeRoleSpark.kt new file mode 100644 index 00000000..3205a264 --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeRoleSpark.kt @@ -0,0 +1,56 @@ +package link.socket.ampere.agents.domain.cognition.sparks + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import link.socket.ampere.agents.domain.cognition.FileAccessScope +import link.socket.ampere.agents.domain.cognition.Spark +import link.socket.ampere.agents.domain.cognition.ToolId + +/** + * A capability-bearing role spark authored as a JSON-frontmatter `.spark.md` + * document instead of a sealed `RoleSpark.` subclass. + * + * Mirrors [DeclarativePhaseSpark] for the role axis: the runtime spark + * surfaces its body as a single [promptContribution] block (no + * `## When ` extraction) and exposes [allowedTools] / + * [fileAccessScope] verbatim from the parsed frontmatter. Capability + * narrowing against the surrounding stack is the existing + * [link.socket.ampere.agents.domain.cognition.SparkStack.effectiveAllowedTools] + * / [link.socket.ampere.agents.domain.cognition.SparkStack.effectiveFileAccess] + * intersection semantics — no new composition logic here. + * + * `name` is delegated to the frontmatter so trace projections keep the + * historical `Role:` bucket: the seed fixture declares + * `"name": "Role:Code"`, matching the [RoleSpark.Code] singleton it + * replaces at the [link.socket.ampere.agents.definition.SparkBasedAgent] + * factory callsites. + */ +@Serializable +@SerialName("Role.Declarative") +internal data class DeclarativeRoleSpark( + val sparkId: String, + val displayName: String, + override val promptContribution: String, + override val agentRole: String?, + override val requestedToolIds: Set, + override val allowedTools: Set?, + override val fileAccessScope: FileAccessScope?, +) : Spark { + override val name: String = displayName +} + +/** + * Converts a parsed [DeclarativeSparkSource.Role] into the runtime spark the + * factory callsites push onto a stack. Pure mapping; capability composition + * lives on the stack, not here. + */ +internal fun DeclarativeSparkSource.Role.toRoleSpark(): DeclarativeRoleSpark = + DeclarativeRoleSpark( + sparkId = frontmatter.id, + displayName = frontmatter.name, + promptContribution = body, + agentRole = frontmatter.agentRole, + requestedToolIds = frontmatter.requestedToolIds, + allowedTools = frontmatter.allowedTools, + fileAccessScope = frontmatter.fileAccessScope?.toDomain(), + ) diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeSparkSource.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeSparkSource.kt new file mode 100644 index 00000000..401b2240 --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeSparkSource.kt @@ -0,0 +1,63 @@ +package link.socket.ampere.agents.domain.cognition.sparks + +/** + * Sealed parser output for any `.spark.md` document. Replaces the previous + * `DeclarativePhaseSparkSource`-only return shape so the parser can speak + * about phase and role variants under a single naming axis. + * + * Each variant carries its decoded [SparkFrontmatter] sibling plus the + * post-fence body. Adapters (`toPhaseSpark`, `toRoleSpark`) live alongside + * the runtime types they produce, not on this sealed family, so each runtime + * spark owns its conversion contract. + */ +internal sealed interface DeclarativeSparkSource { + val id: String + val name: String + val body: String + + /** + * A `"phase"` spark: prompt-only, optionally split into per-phase sections + * via `## When Perceiving/Planning/Executing/Learning` headers. + */ + data class Phase( + val frontmatter: PhaseSparkFrontmatter, + override val body: String, + val phaseContributions: Map, + ) : DeclarativeSparkSource { + override val id: String get() = frontmatter.id + override val name: String get() = frontmatter.name + } + + /** + * A `"role"` spark: capability-bearing, single body block surfaced + * directly as `promptContribution`. No per-phase section extraction — + * role guidance applies uniformly across phases. + */ + data class Role( + val frontmatter: RoleSparkFrontmatter, + override val body: String, + ) : DeclarativeSparkSource { + override val id: String get() = frontmatter.id + override val name: String get() = frontmatter.name + } +} + +/** + * Bridges a parsed [DeclarativeSparkSource.Phase] back into the existing + * [DeclarativePhaseSparkSource] data path used by [DeclarativePhaseSpark.toPhaseSpark]. + * Keeping this adapter local to the parser layer means downstream phase-spark + * consumers don't need to know the JSON variant exists. + */ +internal fun DeclarativeSparkSource.Phase.toLegacySource(): DeclarativePhaseSparkSource = + DeclarativePhaseSparkSource( + id = frontmatter.id, + name = frontmatter.name, + whenToUse = frontmatter.whenToUse, + body = body, + phases = frontmatter.phases, + tags = frontmatter.tags, + modelPreference = frontmatter.modelPreference, + phaseContributions = phaseContributions, + agentRole = frontmatter.agentRole, + requestedToolIds = frontmatter.requestedToolIds, + ) 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 54dec2aa..1d08a4f4 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 @@ -1,6 +1,7 @@ package link.socket.ampere.agents.domain.cognition.sparks import co.touchlab.kermit.Logger +import link.socket.ampere.agents.domain.cognition.Spark import link.socket.ampere.resources.Res import link.socket.ampere.util.logWith import org.jetbrains.compose.resources.ExperimentalResourceApi @@ -13,14 +14,16 @@ private val DEFAULT_SPARKS: List = listOf( "files/sparks/product-agent.spark.md", "files/sparks/project-agent.spark.md", "files/sparks/quality-agent.spark.md", + "files/sparks/role-code.spark.md", ) /** * In-memory [PhaseSparkLibrary] backed by a pre-parsed set of declarative sparks. * * Construction takes a fully-resolved list of sparks so the runtime accessors - * (`all`, `byId`, `selectFor`) are non-suspend. Use [DefaultPhaseSparkLibrary.load] - * to read and parse the bundled `.spark.md` resources asynchronously. + * (`all`, `byId`, `selectFor`, `roleSparkById`) are non-suspend. Use + * [DefaultPhaseSparkLibrary.load] to read and parse the bundled `.spark.md` + * resources asynchronously. * * The loader mirrors the resource-loading shape of * [link.socket.ampere.domain.ai.pricing.BundledProviderPricingCatalog]: it tries @@ -30,16 +33,19 @@ private val DEFAULT_SPARKS: List = listOf( * never throws. */ internal class DefaultPhaseSparkLibrary internal constructor( - private val sparks: List, + private val phaseSparks: List, + private val roleSparks: Map = emptyMap(), ) : PhaseSparkLibrary { - override fun all(): List = sparks + override fun all(): List = phaseSparks override fun byId(id: PhaseSparkId): PhaseSpark? = - sparks.firstOrNull { spark -> spark is DeclarativePhaseSpark && spark.sparkId == id } + phaseSparks.firstOrNull { spark -> spark is DeclarativePhaseSpark && spark.sparkId == id } + + override fun roleSparkById(id: String): Spark? = roleSparks[id] override fun selectFor(context: SparkSelectionContext): List { - val phaseMatches = sparks.filterIsInstance() + val phaseMatches = phaseSparks.filterIsInstance() .filter { context.phase in it.eligiblePhases } if (phaseMatches.isEmpty()) return emptyList() @@ -76,8 +82,9 @@ internal class DefaultPhaseSparkLibrary internal constructor( suspend fun load( sparkResourcePaths: List = DEFAULT_SPARKS, ): DefaultPhaseSparkLibrary { - val seenIds = mutableSetOf() - val sparks = mutableListOf() + val seenIds = mutableSetOf() + val phaseSparks = mutableListOf() + val roleSparks = mutableMapOf() for (path in sparkResourcePaths) { val raw = readResource(path) if (raw == null) { @@ -92,7 +99,12 @@ internal class DefaultPhaseSparkLibrary internal constructor( "[PhaseSparkLibrary] duplicate spark id '${source.id}' from $path — skipping" } } else { - sparks += source.toPhaseSpark() + when (source) { + is DeclarativeSparkSource.Phase -> + phaseSparks += source.toLegacySource().toPhaseSpark() + is DeclarativeSparkSource.Role -> + roleSparks[source.id] = source.toRoleSpark() + } } } is SparkParseResult.Failed -> { @@ -102,14 +114,18 @@ internal class DefaultPhaseSparkLibrary internal constructor( } } } - return DefaultPhaseSparkLibrary(sparks.toList()) + return DefaultPhaseSparkLibrary( + phaseSparks = phaseSparks.toList(), + roleSparks = roleSparks.toMap(), + ) } /** - * Builds a library from already-parsed sources (useful for tests). + * Builds a library from already-parsed phase sources (useful for tests + * that exercise the phase-spark surface). */ internal fun fromSources(sources: List): DefaultPhaseSparkLibrary = - DefaultPhaseSparkLibrary(sources.map { it.toPhaseSpark() }) + DefaultPhaseSparkLibrary(phaseSparks = sources.map { it.toPhaseSpark() }) @OptIn(ExperimentalResourceApi::class) private suspend fun readResource(path: String): String? = diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/PhaseSparkLibrary.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/PhaseSparkLibrary.kt index e8bcc3da..714e5f3b 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/PhaseSparkLibrary.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/PhaseSparkLibrary.kt @@ -16,27 +16,27 @@ internal data class SparkSelectionContext( ) /** - * Read-only catalog of declarative [PhaseSpark]s available for selection at - * phase entry. + * Internal phase-spark surface consumed synchronously by [PhaseSparkManager] + * at phase entry, hence the non-suspend methods. Implementations populate + * the catalog asynchronously (e.g. `DefaultPhaseSparkLibrary.load`) and + * hand a fully-resolved library to the caller. * - * The interface is intentionally non-suspend so it can be consulted from - * synchronous code paths such as [PhaseSparkManager.enterPhase]. Implementations - * that need async I/O to populate their catalog should expose a suspend - * factory (e.g. `DefaultPhaseSparkLibrary.load(...)`) that materialises the - * sparks once and hands a fully-resolved library back to the caller. + * Extends [SparkRegistry] so the same instance serves the public role-spark + * lookup used by the [link.socket.ampere.agents.definition.SparkBasedAgent] + * factories — one catalog, two access surfaces split by visibility. * * Implementations should: * - Return deterministic results from [selectFor] (stable order across runs) * - Never throw from these accessors; surface failures via empty results */ -internal interface PhaseSparkLibrary { +internal interface PhaseSparkLibrary : SparkRegistry { - /** All declarative sparks the library could expose, regardless of selection. */ + /** All phase sparks the library could expose, regardless of selection. */ fun all(): List - /** Returns the spark with [id] for any phase, or null if none. */ + /** Returns the phase spark with [id], or null if none. */ fun byId(id: PhaseSparkId): PhaseSpark? - /** Returns declarative sparks matching [context], ordered deterministically. */ + /** Returns phase sparks matching [context], ordered deterministically. */ fun selectFor(context: SparkSelectionContext): List } diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkFrontmatter.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkFrontmatter.kt new file mode 100644 index 00000000..9f16387c --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkFrontmatter.kt @@ -0,0 +1,89 @@ +package link.socket.ampere.agents.domain.cognition.sparks + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import link.socket.ampere.agents.domain.cognition.FileAccessScope + +/** + * Sealed schema for the JSON frontmatter that opens every `.spark.md` document. + * + * The fence is `---json` / `---`; the body between fences must decode to a + * variant of [SparkFrontmatter] via a [kotlinx.serialization.json.Json] instance + * configured with `classDiscriminator = "type"`, `ignoreUnknownKeys = false`, + * and `encodeDefaults = true`. Unknown fields are a parse error by intent — + * spark frontmatter is capability-bearing, and silently dropping a misspelled + * `requestedToolIds` would let a spark ship without the tools it expects. + * + * The two production variants today are [PhaseSparkFrontmatter] (`"phase"`) + * and [RoleSparkFrontmatter] (`"role"`). Future variants + * (`"project"`, `"language"`, `"task"`, `"coordination"`) plug into the same + * discriminator without touching call sites that consume the sealed type. + */ +@Serializable +internal sealed interface SparkFrontmatter { + val id: String + val name: String +} + +/** + * Frontmatter shape for a phase-oriented spark — prompt-only, no + * capability narrowing. Mirrors the historical YAML fields of + * [DeclarativePhaseSparkSource] one-for-one so the migration is a fence + * rewrite, not a schema overhaul. + */ +@Serializable +@SerialName("phase") +internal data class PhaseSparkFrontmatter( + override val id: String, + override val name: String, + val whenToUse: String, + val phases: Set = setOf(CognitivePhase.PLAN), + val tags: Set = emptySet(), + val modelPreference: String? = null, + val agentRole: String? = null, + val requestedToolIds: Set = emptySet(), +) : SparkFrontmatter + +/** + * Frontmatter shape for a role spark — capability-bearing. Carries the tool + * narrowing and file-access scope that built-in role sparks (e.g. + * [RoleSpark.Code]) encode in Kotlin today. The body of the spark document + * becomes the role's `promptContribution` directly; there is no per-phase + * section extraction for role variants. + * + * `allowedTools = null` preserves the "inherits from parent context" semantics + * documented on [link.socket.ampere.agents.domain.cognition.Spark.allowedTools]; + * an empty set explicitly narrows to "no tools allowed" and is **not** the same + * as omitting the field. + */ +@Serializable +@SerialName("role") +internal data class RoleSparkFrontmatter( + override val id: String, + override val name: String, + val agentRole: String, + val requestedToolIds: Set = emptySet(), + val allowedTools: Set? = null, + val fileAccessScope: FileAccessScopeFrontmatter? = null, +) : SparkFrontmatter + +/** + * Glob-pattern bundle for [FileAccessScope], shaped for ergonomic JSON + * authoring (one field per pattern axis). + * + * Empty sets in JSON map directly to empty sets in the domain object; the + * domain class already encodes "empty means no access unless inherited from + * parent" via its companion defaults. + */ +@Serializable +internal data class FileAccessScopeFrontmatter( + val read: Set = emptySet(), + val write: Set = emptySet(), + val forbidden: Set = emptySet(), +) { + fun toDomain(): FileAccessScope = FileAccessScope( + readPatterns = read, + writePatterns = write, + forbiddenPatterns = forbidden, + ) +} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkParser.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkParser.kt index 9c25edd0..53533b8b 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkParser.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkParser.kt @@ -1,12 +1,15 @@ package link.socket.ampere.agents.domain.cognition.sparks +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json + /** * Outcome of [parseSpark]. Either a parsed source declaration or a typed * error describing why parsing failed. */ internal sealed interface SparkParseResult { - data class Ok(val source: DeclarativePhaseSparkSource) : SparkParseResult + data class Ok(val source: DeclarativeSparkSource) : SparkParseResult data class Failed(val error: SparkParseError) : SparkParseResult } @@ -17,15 +20,29 @@ internal sealed interface SparkParseResult { */ internal sealed interface SparkParseError { - /** The document lacks the required `---` frontmatter fence. */ + /** The document lacks any recognised frontmatter fence. */ data object MissingFrontmatter : SparkParseError + /** + * The document opens with the legacy `---` YAML fence. Until AMPR-165 + * removes the legacy parser entirely, this surfaces as a typed error so + * fixtures that didn't migrate are visible during rollout instead of + * silently producing empty libraries. + */ + data object DeprecatedYamlFrontmatter : SparkParseError + /** A required frontmatter field is missing. */ data class MissingRequiredField(val field: String) : SparkParseError /** Frontmatter is structurally invalid (e.g. malformed key/value line). */ data class InvalidFrontmatter(val message: String) : SparkParseError + /** JSON frontmatter could not be decoded by `kotlinx-serialization`. */ + data class InvalidJson(val message: String) : SparkParseError + + /** JSON frontmatter declared a `"type"` discriminator with no matching variant. */ + data class UnknownDiscriminator(val value: String) : SparkParseError + /** Frontmatter declared a phase value that is not a [CognitivePhase] name. */ data class InvalidPhase(val value: String) : SparkParseError @@ -34,115 +51,148 @@ internal sealed interface SparkParseError { } private const val FENCE = "---" +private const val JSON_OPEN_FENCE = "---json" + +/** + * Dedicated [Json] instance for spark frontmatter. + * + * - `classDiscriminator = "type"` matches the convention used by sibling + * discriminated unions in this codebase (e.g. repository / plugin bundle). + * - `ignoreUnknownKeys = false` — spark frontmatter is capability-bearing, so a + * misspelled `requestedToolIds` must fail loudly rather than silently drop. + * - `encodeDefaults = true` keeps round-trip serialization stable, which the + * parse-equivalence test in AMPR-165 Wave 1 leans on. + */ +private val sparkJson: Json = Json { + classDiscriminator = "type" + ignoreUnknownKeys = false + encodeDefaults = true +} /** - * Parses a `.spark.md` document into a [DeclarativePhaseSparkSource]. + * Parses a `.spark.md` document. + * + * The expected shape since AMPR-165 is a JSON frontmatter block fenced by + * `---json` / `---`: * - * Expected shape: * ``` - * --- - * id: cooking-domain - * name: Cooking Domain - * whenToUse: tasks that mention recipes, ingredients, or meal planning - * phases: PLAN, EXECUTE - * tags: cooking, recipes - * modelPreference: gpt-4o-mini + * ---json + * { + * "type": "phase", + * "id": "cooking-domain", + * ... + * } * --- * * Body markdown here. * ``` * - * Hand-rolled (no Kaml dependency). Values are everything after the first `:` - * on a line; comma-separated lists are split for `phases` and `tags`. If a - * single key requires multi-line value support, **stop and file a - * Kaml-in-commonMain follow-up** before extending this parser. + * Documents that still open with a bare `---` fence are rejected with + * [SparkParseError.DeprecatedYamlFrontmatter] so unmigrated fixtures surface + * during rollout instead of producing a quietly-empty library. */ internal fun parseSpark(raw: String): SparkParseResult { - val normalized = raw.replace("\r\n", "\n") - val trimmedStart = normalized.trimStart('\n', ' ', '\t') - - if (!trimmedStart.startsWith("$FENCE\n") && trimmedStart != FENCE && !trimmedStart.startsWith("$FENCE\r")) { - return SparkParseResult.Failed(SparkParseError.MissingFrontmatter) + val trimmed = raw.replace("\r\n", "\n").trimStart('\n', ' ', '\t') + return when { + trimmed.startsWith("$JSON_OPEN_FENCE\n") || trimmed == JSON_OPEN_FENCE -> + parseJsonSpark(trimmed) + trimmed.startsWith("$FENCE\n") || trimmed == FENCE -> + SparkParseResult.Failed(SparkParseError.DeprecatedYamlFrontmatter) + else -> + SparkParseResult.Failed(SparkParseError.MissingFrontmatter) } +} - val afterOpenFence = trimmedStart.removePrefix(FENCE).trimStart('\n') +/** + * Parses a `---json` / `---` fenced document. Assumes [trimmed] has already + * been normalised (no `\r\n`, no leading whitespace) and starts with the + * `---json` fence. + */ +private fun parseJsonSpark(trimmed: String): SparkParseResult { + val afterOpenFence = trimmed.removePrefix(JSON_OPEN_FENCE).trimStart('\n') val closeIndex = findClosingFence(afterOpenFence) ?: return SparkParseResult.Failed(SparkParseError.MissingFrontmatter) - val frontmatterBlock = afterOpenFence.substring(0, closeIndex).trimEnd('\n') + val jsonBlock = afterOpenFence.substring(0, closeIndex).trimEnd('\n') val body = afterOpenFence.substring(closeIndex) .removePrefix(FENCE) .trim('\n', ' ', '\t') - val fields = mutableMapOf() - for ((lineNumber, rawLine) in frontmatterBlock.lines().withIndex()) { - val line = rawLine.trim() - if (line.isEmpty()) continue + val frontmatter = try { + sparkJson.decodeFromString(SparkFrontmatter.serializer(), jsonBlock) + } catch (ex: SerializationException) { + return SparkParseResult.Failed(classifyJsonError(ex)) + } - val sepIndex = line.indexOf(':') - if (sepIndex <= 0) { - return SparkParseResult.Failed( - SparkParseError.InvalidFrontmatter("line ${lineNumber + 1}: expected 'key: value'"), + if (body.isEmpty()) { + return SparkParseResult.Failed(SparkParseError.EmptyBody) + } + + return when (frontmatter) { + is PhaseSparkFrontmatter -> { + val (baseBody, phaseContributions) = extractPhaseSections(body) + SparkParseResult.Ok( + DeclarativeSparkSource.Phase( + frontmatter = frontmatter, + body = baseBody, + phaseContributions = phaseContributions, + ), ) } - val key = line.substring(0, sepIndex).trim() - val value = line.substring(sepIndex + 1).trim() - if (key.isEmpty()) { - return SparkParseResult.Failed( - SparkParseError.InvalidFrontmatter("line ${lineNumber + 1}: blank key"), + is RoleSparkFrontmatter -> { + // Role sparks keep their body as a single block — no `## When ` + // extraction. Role guidance applies uniformly across phases by design. + SparkParseResult.Ok( + DeclarativeSparkSource.Role( + frontmatter = frontmatter, + body = body, + ), ) } - fields[key] = value } +} - val id = fields["id"]?.takeIf { it.isNotEmpty() } - ?: return SparkParseResult.Failed(SparkParseError.MissingRequiredField("id")) - val name = fields["name"]?.takeIf { it.isNotEmpty() } - ?: return SparkParseResult.Failed(SparkParseError.MissingRequiredField("name")) - val whenToUse = fields["whenToUse"]?.takeIf { it.isNotEmpty() } - ?: return SparkParseResult.Failed(SparkParseError.MissingRequiredField("whenToUse")) - - val phases = when (val phasesRaw = fields["phases"]) { - null, "" -> setOf(CognitivePhase.PLAN) - else -> { - val parsed = mutableSetOf() - for (token in phasesRaw.split(',')) { - val trimmed = token.trim() - if (trimmed.isEmpty()) continue - val phase = parsePhase(trimmed) - ?: return SparkParseResult.Failed(SparkParseError.InvalidPhase(trimmed)) - parsed += phase - } - if (parsed.isEmpty()) setOf(CognitivePhase.PLAN) else parsed - } +/** + * Maps a [SerializationException] thrown by [sparkJson] into the matching + * [SparkParseError] variant. Keeping the mapping in one place lets the parser + * surface stable, typed errors without leaking kotlinx-serialization's + * exception hierarchy to callers. + */ +private fun classifyJsonError(ex: SerializationException): SparkParseError { + val message = ex.message.orEmpty() + return when { + // kotlinx-serialization's SerializerNotFoundException message contains the + // discriminator value. Format example: + // "Serializer for subclass 'unknown' is not found in the polymorphic scope of 'SparkFrontmatter'." + message.contains("polymorphic", ignoreCase = true) || + message.contains("discriminator", ignoreCase = true) || + message.contains("subclass", ignoreCase = true) -> + SparkParseError.UnknownDiscriminator(extractDiscriminatorValue(message)) + // MissingFieldException carries the missing field name. + message.contains("missing", ignoreCase = true) && message.contains("field", ignoreCase = true) -> + SparkParseError.MissingRequiredField(extractMissingFieldName(message)) + // CognitivePhase decoding failure surfaces the unrecognised enum string. + (message.contains("CognitivePhase") || message.contains("enum", ignoreCase = true)) && + message.contains("does not contain element", ignoreCase = true) -> + SparkParseError.InvalidPhase(extractEnumValue(message)) + else -> SparkParseError.InvalidJson(message) } +} - val tags = fields["tags"].splitCommaSeparated() - val modelPreference = fields["modelPreference"]?.takeIf { it.isNotEmpty() } - val agentRole = fields["agentRole"]?.takeIf { it.isNotEmpty() } - val requestedToolIds = fields["requestedToolIds"].splitCommaSeparated() +private val DISCRIMINATOR_QUOTE_REGEX = Regex("[`']([^`']+)[`']") - if (body.isEmpty()) { - return SparkParseResult.Failed(SparkParseError.EmptyBody) - } +private fun extractDiscriminatorValue(message: String): String = + DISCRIMINATOR_QUOTE_REGEX.find(message)?.groupValues?.getOrNull(1) ?: "" - val (baseBody, phaseContributions) = extractPhaseSections(body) - - return SparkParseResult.Ok( - DeclarativePhaseSparkSource( - id = id, - name = name, - whenToUse = whenToUse, - body = baseBody, - phases = phases, - tags = tags, - modelPreference = modelPreference, - phaseContributions = phaseContributions, - agentRole = agentRole, - requestedToolIds = requestedToolIds, - ), - ) -} +private val MISSING_FIELD_REGEX = Regex("[`']([A-Za-z_][A-Za-z0-9_]*)[`']") + +private fun extractMissingFieldName(message: String): String = + MISSING_FIELD_REGEX.find(message)?.groupValues?.getOrNull(1) ?: "" + +private val ENUM_VALUE_REGEX = Regex("contain element with name [`']([^`']+)[`']", RegexOption.IGNORE_CASE) + +private fun extractEnumValue(message: String): String = + ENUM_VALUE_REGEX.find(message)?.groupValues?.getOrNull(1) ?: "" /** * Splits a spark body into the always-on base content plus per-phase sections @@ -210,11 +260,3 @@ private fun findClosingFence(text: String): Int? { searchStart = candidate + 1 } } - -private fun parsePhase(value: String): CognitivePhase? = - CognitivePhase.entries.firstOrNull { it.name.equals(value, ignoreCase = true) } - -private fun String?.splitCommaSeparated(): Set { - if (this.isNullOrBlank()) return emptySet() - return split(',').map { it.trim() }.filter { it.isNotEmpty() }.toSet() -} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkRegistry.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkRegistry.kt new file mode 100644 index 00000000..226cb38c --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkRegistry.kt @@ -0,0 +1,26 @@ +package link.socket.ampere.agents.domain.cognition.sparks + +import link.socket.ampere.agents.domain.cognition.Spark + +/** + * Public-facing registry of declarative sparks bundled with the runtime. + * + * External code paths — most notably the [link.socket.ampere.agents.definition.SparkBasedAgent.Code] + * and [link.socket.ampere.agents.definition.SparkBasedAgent.Quality] factories + * — resolve their role spark through this surface, by canonical id, so the + * factory call site doesn't need to know about the internal phase-spark + * selection machinery exposed by [PhaseSparkLibrary]. + * + * Implementations are expected to be deterministic across calls and never + * throw from accessors (surface "not found" as null). + */ +interface SparkRegistry { + + /** + * Returns the role spark loaded from a `"role"` JSON fixture with the + * given canonical [id], or null if no such fixture is bundled. The id + * matches the `"id"` field of the fixture's frontmatter (e.g. `"code"` + * for `role-code.spark.md`) — not the human-readable `Role:Code` name. + */ + fun roleSparkById(id: String): Spark? +} 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 ab51259f..6a14e821 100644 --- a/ampere-core/src/commonTest/kotlin/link/socket/ampere/TestHelpers.kt +++ b/ampere-core/src/commonTest/kotlin/link/socket/ampere/TestHelpers.kt @@ -164,12 +164,3 @@ fun stubQualityAssuranceState( plan = plan, outcome = outcome, ) - -fun stubQualityAssuranceAgent( - @Suppress("UNUSED_PARAMETER") initialState: QualityState = stubQualityAssuranceState(), - agentConfiguration: AgentConfiguration = stubAgentConfiguration(), -): SparkBasedAgent = - SparkBasedAgent.Quality( - agentId = "stub-quality-agent", - aiConfiguration = agentConfiguration.aiConfiguration, - ) diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkParserJsonTest.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkParserJsonTest.kt new file mode 100644 index 00000000..c6efea81 --- /dev/null +++ b/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkParserJsonTest.kt @@ -0,0 +1,377 @@ +package link.socket.ampere.agents.domain.cognition.sparks + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Covers the public [parseSpark] dispatch path introduced in AMPR-165: + * `---json` / `---` fenced documents decoded via kotlinx-serialization, + * with typed errors for the failure modes the schema can produce. + */ +class SparkParserJsonTest { + + @Test + fun `phase variant happy path parses all fields`() { + val raw = """ + |---json + |{ + | "type": "phase", + | "id": "cooking-domain", + | "name": "Cooking Domain", + | "whenToUse": "tasks involving recipes", + | "phases": ["PLAN", "EXECUTE"], + | "tags": ["cooking", "recipes"], + | "modelPreference": "gpt-4o-mini", + | "agentRole": "Chef", + | "requestedToolIds": ["read_recipe", "convert_units"] + |} + |--- + | + |# Cooking Domain + | + |Apply culinary reasoning. + """.trimMargin() + + val result = parseSpark(raw) + assertIs(result) + val source = assertIs(result.source) + assertEquals("cooking-domain", source.frontmatter.id) + assertEquals("Cooking Domain", source.frontmatter.name) + assertEquals("tasks involving recipes", source.frontmatter.whenToUse) + assertEquals(setOf(CognitivePhase.PLAN, CognitivePhase.EXECUTE), source.frontmatter.phases) + assertEquals(setOf("cooking", "recipes"), source.frontmatter.tags) + assertEquals("gpt-4o-mini", source.frontmatter.modelPreference) + assertEquals("Chef", source.frontmatter.agentRole) + assertEquals(setOf("read_recipe", "convert_units"), source.frontmatter.requestedToolIds) + assertTrue(source.body.startsWith("# Cooking Domain")) + assertTrue(source.body.contains("Apply culinary reasoning.")) + } + + @Test + fun `phase variant extracts per-phase sections from body`() { + val raw = """ + |---json + |{ + | "type": "phase", + | "id": "code-agent", + | "name": "Code Agent", + | "whenToUse": "writing code", + | "phases": ["PERCEIVE", "PLAN", "EXECUTE", "LEARN"] + |} + |--- + | + |You are a code agent. + | + |## When Perceiving + | + |Read existing code first. + | + |## When Planning + | + |Sequence steps. + | + |## When Executing + | + |Run the tools. + | + |## When Learning + | + |Extract knowledge. + """.trimMargin() + + val result = parseSpark(raw) + assertIs(result) + val source = assertIs(result.source) + assertEquals("You are a code agent.", source.body) + assertEquals("Read existing code first.", source.phaseContributions[CognitivePhase.PERCEIVE]) + assertEquals("Sequence steps.", source.phaseContributions[CognitivePhase.PLAN]) + assertEquals("Run the tools.", source.phaseContributions[CognitivePhase.EXECUTE]) + assertEquals("Extract knowledge.", source.phaseContributions[CognitivePhase.LEARN]) + } + + @Test + fun `role variant happy path parses all fields`() { + val raw = """ + |---json + |{ + | "type": "role", + | "id": "code", + | "name": "Role:Code", + | "agentRole": "Code Writer", + | "requestedToolIds": ["read_code_file", "write_code_file"], + | "allowedTools": ["read_code_file", "write_code_file"], + | "fileAccessScope": { + | "read": ["**/*"], + | "write": ["**/*.kt"], + | "forbidden": ["**/.git/**"] + | } + |} + |--- + | + |## Role: Code + | + |You are operating in a code-focused capacity. + """.trimMargin() + + val result = parseSpark(raw) + assertIs(result) + val source = assertIs(result.source) + assertEquals("code", source.frontmatter.id) + assertEquals("Role:Code", source.frontmatter.name) + assertEquals("Code Writer", source.frontmatter.agentRole) + assertEquals(setOf("read_code_file", "write_code_file"), source.frontmatter.requestedToolIds) + assertEquals(setOf("read_code_file", "write_code_file"), source.frontmatter.allowedTools) + val scope = source.frontmatter.fileAccessScope + assertEquals(setOf("**/*"), scope?.read) + assertEquals(setOf("**/*.kt"), scope?.write) + assertEquals(setOf("**/.git/**"), scope?.forbidden) + assertTrue(source.body.startsWith("## Role: Code")) + } + + @Test + fun `role variant keeps body as a single block`() { + // Role guidance applies uniformly across phases — `## When ` headers + // in the body must be preserved verbatim, not extracted into phaseContributions. + val raw = """ + |---json + |{ + | "type": "role", + | "id": "code", + | "name": "Role:Code", + | "agentRole": "Code Writer" + |} + |--- + | + |Role body. + | + |## When Planning + | + |This header should stay inline, not become a phase section. + """.trimMargin() + + val result = parseSpark(raw) + assertIs(result) + val source = assertIs(result.source) + assertTrue(source.body.contains("## When Planning")) + assertTrue(source.body.contains("This header should stay inline")) + } + + @Test + fun `role variant defaults allowedTools and fileAccessScope to null when omitted`() { + val raw = """ + |---json + |{ + | "type": "role", + | "id": "code", + | "name": "Role:Code", + | "agentRole": "Code Writer" + |} + |--- + | + |Body. + """.trimMargin() + + val result = parseSpark(raw) + assertIs(result) + val source = assertIs(result.source) + assertNull(source.frontmatter.allowedTools) + assertNull(source.frontmatter.fileAccessScope) + assertEquals(emptySet(), source.frontmatter.requestedToolIds) + } + + @Test + fun `phase variant defaults phases to PLAN and tags to empty when omitted`() { + val raw = """ + |---json + |{ + | "type": "phase", + | "id": "minimal-edge", + | "name": "Minimal Edge", + | "whenToUse": "smoke test" + |} + |--- + | + |Body text. + """.trimMargin() + + val result = parseSpark(raw) + assertIs(result) + val source = assertIs(result.source) + assertEquals(setOf(CognitivePhase.PLAN), source.frontmatter.phases) + assertEquals(emptySet(), source.frontmatter.tags) + assertNull(source.frontmatter.modelPreference) + assertNull(source.frontmatter.agentRole) + } + + @Test + fun `missing frontmatter fence is reported`() { + val result = parseSpark("just some markdown\nwith no fence") + assertIs(result) + assertEquals(SparkParseError.MissingFrontmatter, result.error) + } + + @Test + fun `deprecated YAML fence is reported`() { + val raw = """ + |--- + |id: legacy + |name: Legacy + |--- + | + |Body. + """.trimMargin() + + val result = parseSpark(raw) + assertIs(result) + assertEquals(SparkParseError.DeprecatedYamlFrontmatter, result.error) + } + + @Test + fun `missing closing JSON fence is reported`() { + val raw = """ + |---json + |{ "type": "phase", "id": "x", "name": "X", "whenToUse": "y" } + | + |No closing fence. + """.trimMargin() + + val result = parseSpark(raw) + assertIs(result) + assertEquals(SparkParseError.MissingFrontmatter, result.error) + } + + @Test + fun `invalid JSON is reported as InvalidJson`() { + val raw = """ + |---json + |{ not valid json at all } + |--- + | + |Body. + """.trimMargin() + + val result = parseSpark(raw) + assertIs(result) + assertIs(result.error) + } + + @Test + fun `unknown discriminator is reported as UnknownDiscriminator`() { + val raw = """ + |---json + |{ + | "type": "language", + | "id": "kotlin", + | "name": "Kotlin" + |} + |--- + | + |Body. + """.trimMargin() + + val result = parseSpark(raw) + assertIs(result) + assertIs(result.error) + } + + @Test + fun `unknown field is reported`() { + // ignoreUnknownKeys = false → unknown keys must fail loudly so misspelled + // capability-bearing fields (e.g. `requestedToolId` singular) are surfaced. + val raw = """ + |---json + |{ + | "type": "phase", + | "id": "x", + | "name": "X", + | "whenToUse": "y", + | "totallyUnknownField": "boom" + |} + |--- + | + |Body. + """.trimMargin() + + val result = parseSpark(raw) + assertIs(result) + // Either InvalidJson or one of the more specific variants; the parser + // surfaces the kotlinx message intact so callers can diagnose. The + // architectural property under test is "must fail", not the specific + // classification of unknown-key errors. + assertTrue( + result.error is SparkParseError.InvalidJson || + result.error is SparkParseError.MissingRequiredField, + "expected unknown field to fail parsing; got ${result.error}", + ) + } + + @Test + fun `invalid phase value is reported`() { + val raw = """ + |---json + |{ + | "type": "phase", + | "id": "bad-phase", + | "name": "Bad Phase", + | "whenToUse": "y", + | "phases": ["PLAN", "MEDITATE"] + |} + |--- + | + |Body. + """.trimMargin() + + val result = parseSpark(raw) + assertIs(result) + assertTrue( + result.error is SparkParseError.InvalidPhase || + result.error is SparkParseError.InvalidJson, + "expected invalid phase to fail parsing; got ${result.error}", + ) + } + + @Test + fun `missing required field is reported`() { + val raw = """ + |---json + |{ + | "type": "phase", + | "id": "no-when", + | "name": "No When" + |} + |--- + | + |Body. + """.trimMargin() + + val result = parseSpark(raw) + assertIs(result) + assertTrue( + result.error is SparkParseError.MissingRequiredField || + result.error is SparkParseError.InvalidJson, + "expected missing required field to fail parsing; got ${result.error}", + ) + } + + @Test + fun `empty body is reported`() { + val raw = """ + |---json + |{ + | "type": "phase", + | "id": "empty", + | "name": "Empty", + | "whenToUse": "y" + |} + |--- + | + """.trimMargin() + + val result = parseSpark(raw) + assertIs(result) + assertEquals(SparkParseError.EmptyBody, result.error) + } +} diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkParserTest.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkParserTest.kt deleted file mode 100644 index 6de25e32..00000000 --- a/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/SparkParserTest.kt +++ /dev/null @@ -1,182 +0,0 @@ -package link.socket.ampere.agents.domain.cognition.sparks - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertTrue - -class SparkParserTest { - - @Test - fun `happy path parses all fields`() { - val raw = """ - |--- - |id: cooking-domain - |name: Cooking Domain - |whenToUse: tasks involving recipes or ingredients - |phases: PLAN, EXECUTE - |tags: cooking, recipes - |modelPreference: gpt-4o-mini - |--- - | - |# Cooking Domain - | - |Apply culinary reasoning. - """.trimMargin() - - val result = parseSpark(raw) - assertIs(result) - val source = result.source - assertEquals("cooking-domain", source.id) - assertEquals("Cooking Domain", source.name) - assertEquals("tasks involving recipes or ingredients", source.whenToUse) - assertEquals(setOf(CognitivePhase.PLAN, CognitivePhase.EXECUTE), source.phases) - assertEquals(setOf("cooking", "recipes"), source.tags) - assertEquals("gpt-4o-mini", source.modelPreference) - assertTrue(source.body.startsWith("# Cooking Domain")) - assertTrue(source.body.contains("Apply culinary reasoning.")) - } - - @Test - fun `defaults phases to PLAN and tags to empty when omitted`() { - val raw = """ - |--- - |id: minimal-edge - |name: Minimal Edge - |whenToUse: edge-case fallback - |--- - | - |Body text. - """.trimMargin() - - val result = parseSpark(raw) - assertIs(result) - assertEquals(setOf(CognitivePhase.PLAN), result.source.phases) - assertEquals(emptySet(), result.source.tags) - assertEquals(null, result.source.modelPreference) - } - - @Test - fun `missing opening frontmatter fence is reported`() { - val raw = "no frontmatter here\nat all" - val result = parseSpark(raw) - assertIs(result) - assertEquals(SparkParseError.MissingFrontmatter, result.error) - } - - @Test - fun `missing closing frontmatter fence is reported`() { - val raw = """ - |--- - |id: orphan - |name: Orphan - |whenToUse: never - | - |Body without closing fence. - """.trimMargin() - - val result = parseSpark(raw) - assertIs(result) - assertEquals(SparkParseError.MissingFrontmatter, result.error) - } - - @Test - fun `missing id is reported`() { - val raw = """ - |--- - |name: No Id - |whenToUse: testing - |--- - | - |Body. - """.trimMargin() - - val result = parseSpark(raw) - assertIs(result) - assertEquals(SparkParseError.MissingRequiredField("id"), result.error) - } - - @Test - fun `missing name is reported`() { - val raw = """ - |--- - |id: no-name - |whenToUse: testing - |--- - | - |Body. - """.trimMargin() - - val result = parseSpark(raw) - assertIs(result) - assertEquals(SparkParseError.MissingRequiredField("name"), result.error) - } - - @Test - fun `missing whenToUse is reported`() { - val raw = """ - |--- - |id: no-when - |name: No When - |--- - | - |Body. - """.trimMargin() - - val result = parseSpark(raw) - assertIs(result) - assertEquals(SparkParseError.MissingRequiredField("whenToUse"), result.error) - } - - @Test - fun `invalid phase is reported`() { - val raw = """ - |--- - |id: bad-phase - |name: Bad Phase - |whenToUse: testing - |phases: PLAN, MEDITATE - |--- - | - |Body. - """.trimMargin() - - val result = parseSpark(raw) - assertIs(result) - assertEquals(SparkParseError.InvalidPhase("MEDITATE"), result.error) - } - - @Test - fun `empty body is reported`() { - val raw = """ - |--- - |id: empty - |name: Empty - |whenToUse: testing - |--- - | - """.trimMargin() - - val result = parseSpark(raw) - assertIs(result) - assertEquals(SparkParseError.EmptyBody, result.error) - } - - @Test - fun `malformed frontmatter line is reported`() { - val raw = """ - |--- - |id: bad-line - |name: Bad Line - |whenToUse: testing - |not-a-key-value-line - |--- - | - |Body. - """.trimMargin() - - val result = parseSpark(raw) - assertIs(result) - assertIs(result.error) - } -} 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 index 958f4a39..b84f1e11 100644 --- 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 @@ -2,16 +2,18 @@ package link.socket.ampere.agents.definition import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking 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.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.cognition.sparks.SparkSelectionContext import link.socket.ampere.agents.execution.request.ExecutionContext import link.socket.ampere.agents.execution.tools.FunctionTool @@ -30,6 +32,8 @@ import link.socket.ampere.domain.ai.provider.AIProvider */ class SparkBasedAgentCodeFactoryTest { + private val phaseSparkLibrary: PhaseSparkLibrary = runBlocking { DefaultPhaseSparkLibrary.load() } + private class FakeAIConfiguration : AIConfiguration { override val provider: AIProvider<*, *> get() = throw NotImplementedError("provider should not be invoked in factory test") @@ -42,14 +46,38 @@ class SparkBasedAgentCodeFactoryTest { @Test fun `factory builds an analytical code agent with the role spark on top`() { val agent = SparkBasedAgent.Code( + sparkRegistry = phaseSparkLibrary, agentId = "code-factory-test", aiConfiguration = FakeAIConfiguration(), ) assertEquals(CognitiveAffinity.ANALYTICAL, agent.affinity) + // Role spark id is "code"; declarative fixture's name field is "Role:Code" + // (matching the legacy RoleSpark.Code singleton's name). + assertTrue( + agent.cognitiveState.endsWith("[Role:Code]"), + "declarative role-code spark should be the most recently applied spark; got: ${agent.cognitiveState}", + ) + } + + @Test + fun `factory fails fast when the library has no role-code fixture`() { + // Library loaded with a single non-role fixture: lookup must fail loudly + // rather than silently fall back to the legacy RoleSpark.Code singleton. + val emptyLibrary: PhaseSparkLibrary = runBlocking { + DefaultPhaseSparkLibrary.load(sparkResourcePaths = listOf("files/sparks/minimal-edge.spark.md")) + } + + val failure = assertFailsWith { + SparkBasedAgent.Code( + sparkRegistry = emptyLibrary, + agentId = "code-factory-no-library-test", + aiConfiguration = FakeAIConfiguration(), + ) + } assertTrue( - agent.cognitiveState.endsWith("[${RoleSpark.Code.name}]"), - "RoleSpark.Code should be the most recently applied spark; got: ${agent.cognitiveState}", + failure.message?.contains("role-code") == true, + "error should name the missing fixture; got: ${failure.message}", ) } @@ -63,6 +91,7 @@ class SparkBasedAgentCodeFactoryTest { executionFunction = { error("not used in this test") }, ) val agent = SparkBasedAgent.Code( + sparkRegistry = phaseSparkLibrary, agentId = "code-factory-test-tools", aiConfiguration = FakeAIConfiguration(), tools = setOf(noopTool), @@ -80,11 +109,12 @@ class SparkBasedAgentCodeFactoryTest { @Test fun `wiring a PhaseSparkLibrary lets the code-agent declarative spark activate during a phase`() = runTest { + val library = DefaultPhaseSparkLibrary.load() val agent = SparkBasedAgent.Code( + sparkRegistry = library, agentId = "code-factory-test-library", aiConfiguration = FakeAIConfiguration(), ) - val library = DefaultPhaseSparkLibrary.load() agent.setPhaseSparkLibrary(library) // The code-agent spark must be in the loaded library. 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 index 1605f2af..08dec536 100644 --- 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 @@ -7,6 +7,8 @@ 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.cognition.sparks.DefaultPhaseSparkLibrary +import link.socket.ampere.agents.domain.cognition.sparks.PhaseSparkLibrary import link.socket.ampere.agents.domain.expectation.Expectations import link.socket.ampere.agents.domain.outcome.ExecutionOutcome import link.socket.ampere.agents.domain.outcome.Outcome @@ -31,6 +33,8 @@ import link.socket.ampere.agents.execution.tools.Tool */ class SparkBasedAgentStepRoutingTest { + private val phaseSparkLibrary: PhaseSparkLibrary = runBlocking { DefaultPhaseSparkLibrary.load() } + /** A recording tool that captures every invocation for later assertion. */ private class RecordingTool(val id: String) { val invocations: MutableList> = CopyOnWriteArrayList() @@ -87,6 +91,7 @@ class SparkBasedAgentStepRoutingTest { } } val agent = SparkBasedAgent.Code( + sparkRegistry = phaseSparkLibrary, agentId = "routing-agent", tools = setOf(recorder.tool), reasoningOverride = reasoning, @@ -115,6 +120,7 @@ class SparkBasedAgentStepRoutingTest { onToolExecution { _, _ -> error("must not be reached when toolId is unknown") } } val agent = SparkBasedAgent.Code( + sparkRegistry = phaseSparkLibrary, agentId = "routing-agent", tools = setOf(recorder.tool), reasoningOverride = reasoning, @@ -143,6 +149,7 @@ class SparkBasedAgentStepRoutingTest { onToolExecution { _, _ -> error("must not be reached when toolId is null") } } val agent = SparkBasedAgent.Code( + sparkRegistry = phaseSparkLibrary, agentId = "routing-agent", tools = setOf(recorder.tool), reasoningOverride = reasoning, @@ -185,6 +192,7 @@ class SparkBasedAgentStepRoutingTest { } } val agent = SparkBasedAgent.Code( + sparkRegistry = phaseSparkLibrary, agentId = "routing-agent", tools = setOf>(first.tool, second.tool), reasoningOverride = reasoning, 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 0913adda..3de729a2 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 @@ -11,6 +11,7 @@ import kotlinx.datetime.Clock import link.socket.ampere.agents.config.AgentActionAutonomy import link.socket.ampere.agents.config.AgentConfiguration import link.socket.ampere.agents.definition.SparkBasedAgent +import link.socket.ampere.agents.domain.cognition.sparks.DefaultPhaseSparkLibrary import link.socket.ampere.agents.domain.outcome.ExecutionOutcome import link.socket.ampere.agents.domain.status.TaskStatus import link.socket.ampere.agents.domain.task.Task @@ -63,6 +64,7 @@ class CognitiveCycleDemo { // Create the spark-based code agent val agent = SparkBasedAgent.Code( + sparkRegistry = DefaultPhaseSparkLibrary.load(), agentId = "cognitive-cycle-demo-agent", aiConfiguration = agentConfig.aiConfiguration, tools = setOf(mockWriteCodeFile), diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeRoleSparkTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeRoleSparkTest.kt new file mode 100644 index 00000000..ee82e2b2 --- /dev/null +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/DeclarativeRoleSparkTest.kt @@ -0,0 +1,98 @@ +package link.socket.ampere.agents.domain.cognition.sparks + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import link.socket.ampere.agents.domain.cognition.CognitiveAffinity +import link.socket.ampere.agents.domain.cognition.SparkStack +import link.socket.ampere.agents.domain.cognition.with + +/** + * Behaviour-equivalence check between the declarative [DeclarativeRoleSpark] + * loaded from `role-code.spark.md` and the [RoleSpark.Code] singleton it + * replaces. Asserts that on the same affinity, the two sparks produce + * identical effective prompt, tool, role, and file-access projections — + * which is the contract the AMPR-165 Wave 4 factory migration depends on. + */ +class DeclarativeRoleSparkTest { + + @Test + fun `declarative role spark mirrors RoleSpark Code on a fixture stack`() { + val declarative = loadDeclarativeRoleCode() + + val declarativeStack = SparkStack.withAffinity(CognitiveAffinity.ANALYTICAL).with(declarative) + val singletonStack = SparkStack.withAffinity(CognitiveAffinity.ANALYTICAL).with(RoleSpark.Code) + + assertEquals( + singletonStack.buildSystemPrompt().trim(), + declarativeStack.buildSystemPrompt().trim(), + "system prompt diverges between declarative and singleton role-code", + ) + assertEquals(singletonStack.effectiveAgentRole(), declarativeStack.effectiveAgentRole()) + assertEquals(singletonStack.effectiveRequestedTools(), declarativeStack.effectiveRequestedTools()) + assertEquals(singletonStack.effectiveAllowedTools(), declarativeStack.effectiveAllowedTools()) + assertEquals(singletonStack.effectiveFileAccess(), declarativeStack.effectiveFileAccess()) + } + + @Test + fun `declarative role spark name preserves Role colon Subtype trace bucket`() { + val declarative = loadDeclarativeRoleCode() + assertEquals("Role:Code", declarative.name) + assertEquals("code", declarative.sparkId) + } + + @Test + fun `narrowing composes via SparkStack intersection`() { + // A second spark that further narrows to write_code_file only must + // intersect with the declarative role-code's allowed tools, never + // expand them — the SparkStack does the work; the adapter just + // surfaces the per-spark sets. + val declarative = loadDeclarativeRoleCode() + val narrowing = TestNarrowingSpark( + allowedTools = setOf("write_code_file"), + fileAccessScope = null, + ) + val stack = SparkStack.withAffinity(CognitiveAffinity.ANALYTICAL).with(declarative, narrowing) + + assertEquals(setOf("write_code_file"), stack.effectiveAllowedTools()) + } + + private fun loadDeclarativeRoleCode(): DeclarativeRoleSpark { + val raw = readResource("files/sparks/role-code.spark.md") + ?: error("role-code.spark.md is missing from production resources") + val result = parseSpark(raw) + assertIs(result, "role-code parse failed: ${(result as? SparkParseResult.Failed)?.error}") + val role = assertIs(result.source) + val converted = role.toRoleSpark() + assertNotNull(converted) + return converted + } + + private fun readResource(path: String): String? { + val candidates = listOf( + path, + "composeResources/link.socket.ampere.resources/$path", + ) + val classLoaders = listOfNotNull( + Thread.currentThread().contextClassLoader, + DeclarativeRoleSparkTest::class.java.classLoader, + ) + for (cl in classLoaders) { + for (candidate in candidates) { + cl.getResourceAsStream(candidate)?.use { stream -> + return stream.readBytes().decodeToString() + } + } + } + return null + } + + private data class TestNarrowingSpark( + override val allowedTools: Set?, + override val fileAccessScope: link.socket.ampere.agents.domain.cognition.FileAccessScope?, + ) : link.socket.ampere.agents.domain.cognition.Spark { + override val name: String = "Test:Narrowing" + override val promptContribution: String = "" + } +} diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/JsonMigrationEquivalenceTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/JsonMigrationEquivalenceTest.kt new file mode 100644 index 00000000..2f46b676 --- /dev/null +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/agents/domain/cognition/sparks/JsonMigrationEquivalenceTest.kt @@ -0,0 +1,81 @@ +package link.socket.ampere.agents.domain.cognition.sparks + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import link.socket.ampere.agents.domain.cognition.FileAccessScope + +/** + * Regression guard against drift between the `role-code.spark.md` fixture and + * the [RoleSpark.Code] Kotlin singleton it replaces at the + * [link.socket.ampere.agents.definition.SparkBasedAgent] factory callsites. + * + * AMPR-165 also used this file (Wave 1) to assert YAML→JSON parse-equivalence + * for the seven phase fixtures during the atomic migration. Those assertions + * served the migration window and were removed once Wave 2 deleted the YAML + * sources; this single role-code check stays because the fixture and the + * singleton coexist until the follow-up that retires the singleton entirely. + */ +class JsonMigrationEquivalenceTest { + + @Test + fun `role-code JSON fixture matches RoleSpark Code singleton`() { + val role = parseRoleFixture() + + assertEquals("code", role.frontmatter.id) + assertEquals(RoleSpark.Code.name, role.frontmatter.name) + assertEquals(RoleSpark.Code.agentRole, role.frontmatter.agentRole) + assertEquals(RoleSpark.Code.requestedToolIds, role.frontmatter.requestedToolIds) + assertEquals(RoleSpark.Code.allowedTools, role.frontmatter.allowedTools) + + val parsedScope = role.frontmatter.fileAccessScope?.toDomain() + assertNotNull(parsedScope, "role-code fixture must declare a fileAccessScope") + assertEquals(RoleSpark.Code.fileAccessScope.readPatterns, parsedScope.readPatterns) + assertEquals(RoleSpark.Code.fileAccessScope.writePatterns, parsedScope.writePatterns) + assertEquals(RoleSpark.Code.fileAccessScope.forbiddenPatterns, parsedScope.forbiddenPatterns) + + assertEquals( + RoleSpark.Code.promptContribution.trim(), + role.body.trim(), + "role-code body must match RoleSpark.Code.promptContribution", + ) + + val sensitive = FileAccessScope.SensitiveFileForbiddenPatterns + assertTrue( + sensitive.all { it in parsedScope.forbiddenPatterns }, + "role-code fixture missing SensitiveFileForbiddenPatterns entries", + ) + } + + private fun parseRoleFixture(): DeclarativeSparkSource.Role { + val raw = readResource("files/sparks/role-code.spark.md") + ?: error("role-code.spark.md is missing from production resources") + val result = parseSpark(raw) + assertIs(result, "role-code: ${(result as? SparkParseResult.Failed)?.error}") + return assertIs( + result.source, + "role-code: expected role variant, got ${result.source::class.simpleName}", + ) + } + + private fun readResource(path: String): String? { + val candidates = listOf( + path, + "composeResources/link.socket.ampere.resources/$path", + ) + val classLoaders = listOfNotNull( + Thread.currentThread().contextClassLoader, + JsonMigrationEquivalenceTest::class.java.classLoader, + ) + for (cl in classLoaders) { + for (candidate in candidates) { + cl.getResourceAsStream(candidate)?.use { stream -> + return stream.readBytes().decodeToString() + } + } + } + return null + } +} 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 620af100..46fdbe08 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 @@ -2,6 +2,7 @@ package link.socket.ampere.agents.domain.cognition.sparks import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -10,10 +11,11 @@ import kotlinx.coroutines.test.runTest class PhaseSparkLibraryTest { @Test - fun `all returns sparks for every bundled fixture`() = runTest { + fun `all returns phase sparks for every bundled phase fixture`() = runTest { val library = DefaultPhaseSparkLibrary.load() val sparks = library.all().filterIsInstance() + // Role fixtures load via roleSparkById, not via all() — only phase ids appear here. val ids = sparks.map { it.sparkId }.toSet() assertEquals( setOf( @@ -29,6 +31,38 @@ class PhaseSparkLibraryTest { ) } + @Test + fun `roleSparkById resolves bundled role-code fixture`() = runTest { + val library = DefaultPhaseSparkLibrary.load() + + val role = library.roleSparkById("code") + assertNotNull(role, "role-code.spark.md should load via the default library") + val declarative = assertIs(role) + + // Capability surface must match the legacy RoleSpark.Code singleton so the + // Wave 4 factory migration in AMPR-165 is a behaviour-preserving swap. + assertEquals(RoleSpark.Code.name, declarative.name) + assertEquals(RoleSpark.Code.agentRole, declarative.agentRole) + assertEquals(RoleSpark.Code.allowedTools, declarative.allowedTools) + assertEquals(RoleSpark.Code.requestedToolIds, declarative.requestedToolIds) + assertEquals(RoleSpark.Code.fileAccessScope, declarative.fileAccessScope) + } + + @Test + fun `roleSparkById returns null for unknown id`() = runTest { + val library = DefaultPhaseSparkLibrary.load() + assertNull(library.roleSparkById("does-not-exist")) + } + + @Test + fun `byId never returns a role spark`() = runTest { + // Role and phase sparks share the catalog but live in distinct lookup + // surfaces. `byId` is the phase-only door; routing role ids through it + // would let phase-driven code paths accidentally narrow capabilities. + val library = DefaultPhaseSparkLibrary.load() + assertNull(library.byId("code")) + } + @Test fun `code-agent spark parses and exposes per-phase contributions`() = 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 82ff0632..13ada7f6 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 @@ -18,6 +18,7 @@ import kotlinx.serialization.json.Json import link.socket.ampere.agents.config.AgentActionAutonomy import link.socket.ampere.agents.definition.AgentId import link.socket.ampere.agents.definition.SparkBasedAgent +import link.socket.ampere.agents.domain.cognition.sparks.DefaultPhaseSparkLibrary import link.socket.ampere.agents.domain.event.Event import link.socket.ampere.agents.domain.event.EventSource import link.socket.ampere.agents.domain.event.MeetingEvent @@ -55,6 +56,7 @@ class MeetingParticipationHandlerTest { } private val stubAgent = SparkBasedAgent.Code( + sparkRegistry = runBlocking { DefaultPhaseSparkLibrary.load() }, agentId = "meeting-stub-agent", aiConfiguration = AIConfiguration_Default( provider = AIProvider_Google, diff --git a/docs/concepts/spark-system.md b/docs/concepts/spark-system.md index 6ee483ee..d3307bff 100644 --- a/docs/concepts/spark-system.md +++ b/docs/concepts/spark-system.md @@ -10,6 +10,16 @@ related: [PropelLoop, CognitiveRelay, PluginPermissions, CognitionTrace] last_verified: 2026-05-17 --- +> **2026-05-17 (AMPR-165):** Declarative `.spark.md` documents now use JSON +> frontmatter (`---json` / `---`) decoded via a sealed [`SparkFrontmatter`] +> family with `"type"` discriminator. The legacy `---` YAML form is rejected +> at parse time with `SparkParseError.DeprecatedYamlFrontmatter`. The schema +> supports two variants today (`"phase"` and `"role"`) and one role fixture +> (`role-code.spark.md`) backs the [`SparkBasedAgent.Code`] / +> [`SparkBasedAgent.Quality`] factories via the public [`SparkRegistry`] +> surface — those factories no longer reference the `RoleSpark.Code` Kotlin +> singleton. + # Spark System ## What it is @@ -42,20 +52,43 @@ show *exactly* what specialization was active at any point in a run. ### Declarative Sparks -`DeclarativePhaseSpark`s are parsed from `.spark.md` files under -`composeResources/files/sparks/`. A spark file has YAML-style frontmatter -(id, name, whenToUse, phases, tags, agentRole, requestedToolIds, -modelPreference) followed by a markdown body. The body may contain -`## When Perceiving / Planning / Executing / Learning` sections, which the -parser extracts into `phaseContributions`; text outside those headers -becomes the base `promptContribution`. - -`DefaultPhaseSparkLibrary` loads the bundled fixtures at construction time; -`PhaseSparkLibrary.selectFor(SparkSelectionContext)` filters by phase -eligibility, tag intersection, and keyword match against `whenToUse`. -`PhaseSparkManager` consults the library when -`AmpereSpikeFlags.declarativeSparksEnabled` is on, applying both the -built-in `PhaseSpark.forPhase(phase)` and any selected declarative sparks. +Sparks are parsed from `.spark.md` files under +`composeResources/files/sparks/`. Each document opens with a JSON +frontmatter block fenced by `---json` / `---`, decoded against the sealed +`SparkFrontmatter` family via a `Json { classDiscriminator = "type"; +ignoreUnknownKeys = false; encodeDefaults = true }` configuration. Unknown +keys fail parsing by intent: spark frontmatter is capability-bearing, and a +misspelled `requestedToolIds` must not silently ship without its tools. + +Two variants exist today, addressed by the `"type"` discriminator: + +- **`"phase"`** (`PhaseSparkFrontmatter`) — prompt-only guidance. The body + may contain `## When Perceiving / Planning / Executing / Learning` + sections, which the parser extracts into `phaseContributions`; text + outside those headers becomes the base `promptContribution`. Fields: + `id`, `name`, `whenToUse`, `phases`, `tags`, `agentRole`, + `requestedToolIds`, `modelPreference`. +- **`"role"`** (`RoleSparkFrontmatter`) — capability-bearing. The body is + preserved verbatim as `promptContribution` (no per-phase section + extraction; role guidance applies uniformly across phases). Fields: + `id`, `name`, `agentRole`, `requestedToolIds`, `allowedTools`, + `fileAccessScope`. + +Documents that still open with the bare `---` YAML fence are rejected with +`SparkParseError.DeprecatedYamlFrontmatter`. The legacy YAML parser was +removed in AMPR-165 Wave 2. + +`DefaultPhaseSparkLibrary` loads the bundled fixtures at construction +time, dispatching each parse result by variant: `Phase` becomes a +`DeclarativePhaseSpark`, `Role` becomes a `DeclarativeRoleSpark`. +`PhaseSparkLibrary.selectFor(SparkSelectionContext)` filters phase sparks +by eligibility, tag intersection, and keyword match against `whenToUse`; +`SparkRegistry.roleSparkById(id)` resolves role sparks by canonical id +(e.g. `"code"` for `role-code.spark.md`). `PhaseSparkManager` consults +the phase surface when `AmpereSpikeFlags.declarativeSparksEnabled` is on; +the `SparkBasedAgent.Code` / `SparkBasedAgent.Quality` factories consult +the role surface at construction time and fail fast if the bundled +`role-code` fixture is missing. ## Why it exists @@ -92,8 +125,12 @@ exceed parent permissions, so adding a Spark is monotone safe. - `agents/domain/cognition/sparks/CoordinationSpark.kt` — multi-agent coordination context. - `agents/domain/cognition/sparks/PhaseSpark.kt` + `PhaseSparkManager.kt` — `PERCEIVE | PLAN | EXECUTE | LEARN`; manager applies built-in + selected declarative sparks as a list, pops in reverse on phase exit. - `agents/domain/cognition/sparks/DeclarativePhaseSpark.kt` — markdown-authored `PhaseSpark` with `eligiblePhases` + per-phase `phaseContributions`. -- `agents/domain/cognition/sparks/SparkParser.kt` — hand-rolled frontmatter parser; extracts `## When ` sections. -- `agents/domain/cognition/sparks/PhaseSparkLibrary.kt`, `DefaultPhaseSparkLibrary.kt` — read-only catalog with deterministic `selectFor` ordering. +- `agents/domain/cognition/sparks/DeclarativeRoleSpark.kt` — markdown-authored role spark; capability-bearing (`allowedTools`, `fileAccessScope`). +- `agents/domain/cognition/sparks/DeclarativeSparkSource.kt` — sealed parser output: `Phase` / `Role`. +- `agents/domain/cognition/sparks/SparkFrontmatter.kt` — sealed `@Serializable` frontmatter schema with `"phase"` and `"role"` variants. +- `agents/domain/cognition/sparks/SparkParser.kt` — JSON-fenced (`---json` / `---`) parser; extracts `## When ` sections for phase variants only. +- `agents/domain/cognition/sparks/SparkRegistry.kt` — public role-spark lookup (`roleSparkById`) consumed by the agent factories. +- `agents/domain/cognition/sparks/PhaseSparkLibrary.kt`, `DefaultPhaseSparkLibrary.kt` — read-only catalog with deterministic `selectFor` ordering; extends `SparkRegistry`. - `agents/domain/cognition/sparks/AmpereSpikeFlags.kt` — `declarativeSparksEnabled: Boolean = false`; gates declarative spark application. - `composeResources/files/sparks/*.spark.md` — bundled declarative spark fixtures. - `agents/domain/event/SparkAppliedEvent.kt`, `SparkRemovedEvent.kt` — observability. @@ -112,7 +149,8 @@ exceed parent permissions, so adding a Spark is monotone safe. ## Common operations - **Add a new Spark type** — implement `Spark` (or extend an existing sealed family like `PhaseSpark`), define `name`, `promptContribution`, optionally `allowedTools` / `fileAccessScope` / `phaseContributions` / `agentRole` / `requestedToolIds`, mark it `@Serializable` with a stable `@SerialName`. -- **Author a declarative spark** — write a `.spark.md` file under `composeResources/files/sparks/` with frontmatter (id, name, whenToUse required) and a markdown body, optionally with `## When ` sections for phase-specific guidance. Add the path to `DefaultPhaseSparkLibrary.DEFAULT_SPARKS`. +- **Author a declarative phase spark** — write a `.spark.md` file under `composeResources/files/sparks/` with a `---json` / `---` frontmatter block of type `"phase"` (id, name, whenToUse required) and a markdown body, optionally with `## When ` sections for phase-specific guidance. Add the path to `DefaultPhaseSparkLibrary.DEFAULT_SPARKS`. +- **Author a declarative role spark** — same path, `---json` / `---` frontmatter block of type `"role"` (id, name, agentRole required; `allowedTools` / `fileAccessScope` optional for narrowing). Body is the role's `promptContribution` verbatim — do not use `## When ` headers, they will not be extracted. Add the path to `DefaultPhaseSparkLibrary.DEFAULT_SPARKS`. Factory call sites resolve it via `SparkRegistry.roleSparkById(id)`. - **Apply a Spark transiently** — `SparkStack.push(spark)` and ensure a matching `pop` in `finally`. `PhaseSparkManager` handles this for phase boundaries. - **Compose a per-agent stack** — `RoleSpark` + `ProjectSpark` at agent construction, then `PhaseSpark` pushed/popped per phase (potentially multiple when declarative library is active), then `TaskSpark` pushed/popped per task. - **Inspect the active stack** — subscribe to `SparkAppliedEvent` / `SparkRemovedEvent` on the bus, or read `SparkStack.current`.