From 144cfe561da051b593d38788049caf1d45c8d710 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 01:06:27 +0000 Subject: [PATCH 1/8] feat(task T01): implement via codex --- dashboard/src/lib/settings.ts | 10 +++ dashboard/src/types.ts | 12 +++ .../src/v2/lib/settings/project-overrides.ts | 21 ++++- .../architecture/configuration-resolution.md | 3 + docs-web/architecture/data-model.md | 19 +++++ docs-web/developer/settings-reference.md | 30 +++++++ docs-web/user/dashboard/agents.md | 7 ++ docs/architecture/agent-preset-foundation.md | 11 +++ docs/settings/configuration-and-storage.md | 2 + src/contracts/agent-preset-types.ts | 11 +++ src/contracts/app-types.ts | 69 ++++++++++++++++ src/repositories/agent-preset-repository.ts | 69 +++++++++++++++- src/repositories/db/app-db-migrations.ts | 72 +++++++++++++++++ src/repositories/db/app-db-schema.ts | 68 ++++++++++++++++ src/repositories/settings-defaults.ts | 76 +++++++++++++++++ src/repositories/settings-sanitizer.ts | 70 ++++++++++++++++ src/services/settings-resolution-service.ts | 81 ++++++++++++++++++- .../agent-preset-repository.test.ts | 28 +++++++ .../repositories/app-db-storage.test.ts | 9 +++ .../repositories/settings-repository.test.ts | 69 ++++++++++++++++ .../lib/onboarding-provider-settings.test.ts | 14 +++- .../lib/settings-view-models.test.ts | 20 +++++ tests/dashboard/lib/settings.test.ts | 4 + 23 files changed, 769 insertions(+), 6 deletions(-) diff --git a/dashboard/src/lib/settings.ts b/dashboard/src/lib/settings.ts index 70044b73b8..08476d35ea 100644 --- a/dashboard/src/lib/settings.ts +++ b/dashboard/src/lib/settings.ts @@ -87,6 +87,16 @@ export const cloneDefaultSettings = (): DashboardSettings => ({ agentPresetIds: [...DEFAULT_DASHBOARD_SETTINGS.agents.qualityAssurance.completedTaskWithoutPr.agentPresetIds], }, }, + selfReflection: { + planning: { + ...DEFAULT_DASHBOARD_SETTINGS.agents.selfReflection.planning, + criteria: DEFAULT_DASHBOARD_SETTINGS.agents.selfReflection.planning.criteria.map((criterion) => ({ ...criterion })), + }, + qualityAssurance: { + ...DEFAULT_DASHBOARD_SETTINGS.agents.selfReflection.qualityAssurance, + criteria: DEFAULT_DASHBOARD_SETTINGS.agents.selfReflection.qualityAssurance.criteria.map((criterion) => ({ ...criterion })), + }, + }, }, skills: DEFAULT_DASHBOARD_SETTINGS.skills.map((skill) => ({ ...skill })), mcpTools: DEFAULT_DASHBOARD_SETTINGS.mcpTools.map((tool) => ({ ...tool })), diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts index fc0f1f8d96..d0445ca38c 100644 --- a/dashboard/src/types.ts +++ b/dashboard/src/types.ts @@ -39,6 +39,12 @@ import type { AutomationInterventionsSettings, ProviderSettings, SkillToggle, + SkillStorageKind, + SkillSourceType, + SkillStorageRecord, + SkillRecord, + SkillEmbeddingMetadata, + AgentSkillStorageAttachment, McpToolToggle, CustomMcpServer, CustomMcpTransport, @@ -137,6 +143,12 @@ export type { AutomationInterventionsSettings, ProviderSettings, SkillToggle, + SkillStorageKind, + SkillSourceType, + SkillStorageRecord, + SkillRecord, + SkillEmbeddingMetadata, + AgentSkillStorageAttachment, McpToolToggle, CustomMcpServer, CustomMcpTransport, diff --git a/dashboard/src/v2/lib/settings/project-overrides.ts b/dashboard/src/v2/lib/settings/project-overrides.ts index 4879ebdbf2..f946ded8a2 100644 --- a/dashboard/src/v2/lib/settings/project-overrides.ts +++ b/dashboard/src/v2/lib/settings/project-overrides.ts @@ -9,7 +9,7 @@ import type { SkillToggle, SystemSettings, } from "../../../types.js"; -import { cloneGuardrails } from "../../../lib/settings.js"; +import { cloneGuardrails, DEFAULT_DASHBOARD_SETTINGS } from "../../../lib/settings.js"; import { getHintApiKey } from "./provider-instances.js"; const cloneMemorySettings = (memory: ProjectSettings["memory"]): ProjectSettings["memory"] => ({ @@ -46,6 +46,23 @@ const cloneQualityAssuranceSettings = (qa: ProjectSettings["agents"]["qualityAss completedTaskWithoutPr: cloneQualityAssuranceTrigger(qa.completedTaskWithoutPr), }); +const cloneSelfReflectionSettings = ( + settings: ProjectSettings["agents"]["selfReflection"] | undefined, +): ProjectSettings["agents"]["selfReflection"] => ({ + planning: { + ...(settings?.planning ?? DEFAULT_DASHBOARD_SETTINGS.agents.selfReflection.planning), + criteria: (settings?.planning.criteria ?? DEFAULT_DASHBOARD_SETTINGS.agents.selfReflection.planning.criteria) + .map((criterion) => ({ ...criterion })), + }, + qualityAssurance: { + ...(settings?.qualityAssurance ?? DEFAULT_DASHBOARD_SETTINGS.agents.selfReflection.qualityAssurance), + criteria: ( + settings?.qualityAssurance.criteria + ?? DEFAULT_DASHBOARD_SETTINGS.agents.selfReflection.qualityAssurance.criteria + ).map((criterion) => ({ ...criterion })), + }, +}); + const cloneSkills = (skills: SkillToggle[]): SkillToggle[] => skills.map((skill) => ({ ...skill })); const cloneMcpTools = (tools: McpToolToggle[]): McpToolToggle[] => tools.map((tool) => ({ ...tool })); const cloneCustomMcpServers = (servers: CustomMcpServer[] = []): CustomMcpServer[] => servers.map((server) => ({ @@ -164,6 +181,7 @@ export const dashboardSettingsToProjectSettings = (settings: DashboardSettings): routing: cloneAgentRouting(settings.agents.routing), instructionTemplates: { ...settings.agents.instructionTemplates }, qualityAssurance: cloneQualityAssuranceSettings(settings.agents.qualityAssurance), + selfReflection: cloneSelfReflectionSettings(settings.agents.selfReflection), }, skills: cloneSkills(settings.skills), mcpTools: cloneMcpTools(settings.mcpTools), @@ -203,6 +221,7 @@ export const cloneProjectSettings = (settings: ProjectSettings): ProjectSettings routing: cloneAgentRouting(settings.agents.routing), instructionTemplates: { ...settings.agents.instructionTemplates }, qualityAssurance: cloneQualityAssuranceSettings(settings.agents.qualityAssurance), + selfReflection: cloneSelfReflectionSettings(settings.agents.selfReflection), }, skills: cloneSkills(settings.skills), mcpTools: settings.mcpTools ? cloneMcpTools(settings.mcpTools) : undefined, diff --git a/docs-web/architecture/configuration-resolution.md b/docs-web/architecture/configuration-resolution.md index b4138a1637..3189f6e24a 100644 --- a/docs-web/architecture/configuration-resolution.md +++ b/docs-web/architecture/configuration-resolution.md @@ -71,6 +71,7 @@ A field unspecified at higher scopes inherits from lower scopes. The merge is ** - `DEFAULT_PROVIDER_SETTINGS` — per-provider defaults. - `DEFAULT_SKILLS`, `DEFAULT_MCP_TOOL_TOGGLES`, etc. +- `DEFAULT_AGENT_SELF_REFLECTION` — default-off planning and QA self-reflection loop contracts with senior engineering criteria. - `DEFAULT_SPRINT_BRANCH_SCHEME`. System settings on a fresh install are the merge of these defaults plus any external hints applied by the user during onboarding. @@ -84,6 +85,8 @@ Settings changes via `manage_code_ux` → `settings` → `patch_*_setting` (or t There is no need to restart the process for settings changes. +`agents.selfReflection.planning` and `agents.selfReflection.qualityAssurance` are resolved through the same cascade but default to disabled. Each stores criteria with thresholds and a maximum improvement-attempt count; malformed legacy payloads fall back safely during sanitization. Runtime reflection calls are not wired yet. + ### Effective resolution endpoints - `GET /api/projects/:projectId/settings/effective` — merged at project scope. diff --git a/docs-web/architecture/data-model.md b/docs-web/architecture/data-model.md index 98c6f0fc78..149ee2f100 100644 --- a/docs-web/architecture/data-model.md +++ b/docs-web/architecture/data-model.md @@ -17,6 +17,10 @@ Project │ │ └── ExecutionInvocation │ └── PreviewSession ├── AgentPreset +│ └── SkillStorageBinding +├── SkillStorage +│ ├── Skill +│ └── SkillEmbedding ├── Memory │ ├── short-term (sprint-scoped) │ └── long-term (project-scoped) @@ -142,8 +146,23 @@ A granular event in a sprint run (cycle start, task transition, gate decision, M | `avatarConfig` | json | Procedural avatar seed. | | `memoryTemplateOverrideEnabled` | bool | – | | `memoryTemplateMarkdown` | text | If override is on. | +| `persistentSkillStorageIds` | string[] | Storage IDs attached through `agent_skill_storage_bindings`. | +| `persistentSkillStorage.enabled` | bool | Default-off metadata reserved for future skill retrieval. | | `createdAt`, `updatedAt` | datetime | – | +## Persistent Skill Storage + +Persistent skills are stored separately from project workspaces, memories, knowledge documents, and model attachments. + +| Table | Purpose | +| --- | --- | +| `skill_storages` | Named, project-owned storage containers that multiple agents can attach to. | +| `skills` | Individual reusable skill records under a storage container. | +| `skill_embeddings` | Embedding metadata and optional vectors for skill search. | +| `agent_skill_storage_bindings` | Normalized agent-to-storage attachments keyed by `(agent_preset_id, storage_id)`. | + +The current slice defines contracts and persistence only. Runtime provider mounts, prompt injection, MCP tools, and dashboard controls are not wired yet. + ## Memory | Field | Type | Notes | diff --git a/docs-web/developer/settings-reference.md b/docs-web/developer/settings-reference.md index 6d638b4df0..1ff068a306 100644 --- a/docs-web/developer/settings-reference.md +++ b/docs-web/developer/settings-reference.md @@ -17,6 +17,12 @@ Settings are evaluated in cascade: **Defaults → System → Project → Sprint* "cliWorkflow": { /* CLI workflow behavior */ }, "sprintPreview": { /* preview container settings */ }, "git": { /* branches, schemes, GitHub mode */ }, + "agents": { + "selfReflection": { + "planning": { /* default-off reflection loop */ }, + "qualityAssurance": { /* default-off reflection loop */ } + } + }, "skills": [ /* internal skill toggles */ ], "mcpTools": [ /* per-tool enabled flags */ ], "memory": { /* embedding model */ }, @@ -199,6 +205,30 @@ branches — only branches fully contained in the default branch are removed. These are internal skills toggleable for advanced workflows. Most users should not touch them. +## `agents.selfReflection` + +```jsonc +{ + "planning": { + "enabled": false, + "criteria": [ + { "id": "correctness", "label": "Correctness", "prompt": "...", "threshold": 0.85 }, + { "id": "scope_control", "label": "Scope control", "prompt": "...", "threshold": 0.85 } + ], + "maxImprovementAttempts": 1 + }, + "qualityAssurance": { + "enabled": false, + "criteria": [ + { "id": "correctness", "label": "Correctness", "prompt": "...", "threshold": 0.85 } + ], + "maxImprovementAttempts": 1 + } +} +``` + +Both reflection loops are disabled by default and are contracts only until runtime reflection calls and dashboard controls are wired. Criteria are senior engineering checks such as correctness, completeness, decomposition quality, risk handling, testability, maintainability, security, and scope control. Sanitization dedupes criteria by `id`, clamps thresholds to `0..1`, clamps `maxImprovementAttempts` to `0..10`, and falls back to defaults for malformed legacy payloads. + ## `mcpTools` ```jsonc diff --git a/docs-web/user/dashboard/agents.md b/docs-web/user/dashboard/agents.md index 7ed8836967..212f14c9de 100644 --- a/docs-web/user/dashboard/agents.md +++ b/docs-web/user/dashboard/agents.md @@ -7,6 +7,7 @@ An *agent preset* is a reusable persona consisting of: - A **name** and **avatar** (avatar config is auto-generated; you can re-roll it). - A markdown **system instruction** that prepends every session this agent runs. - An optional **memory template** — controls how project / sprint memory is injected into prompts. +- Optional persistent skill storage attachments, stored as shared project skill storage IDs for future retrieval. - A set of **labels** for tagging and filtering. Agent presets show up wherever a chat thread or planning request needs to choose an agent. @@ -76,3 +77,9 @@ A common pattern: have a "Planner" agent (Claude Opus, sober and structured) for ## Memory templates When `memoryTemplateOverrideEnabled` is set, the preset's `memoryTemplateMarkdown` controls how project / sprint memories are formatted into prompts. The template uses simple `{{ }}` placeholders for memory blocks. See [Memory](./memory.md) for available placeholders. + +## Persistent skill storage + +The underlying agent preset contract can now attach an agent to one or more named persistent skill storage records. These records live in dedicated skill tables and are separate from project workspaces, memories, knowledge documents, and provider model attachments. + +This is storage groundwork only: the dashboard does not yet expose controls for editing these attachments, and runtime provider calls do not yet retrieve or mount skills from the stores. diff --git a/docs/architecture/agent-preset-foundation.md b/docs/architecture/agent-preset-foundation.md index 3d3241e745..3a1846b5e1 100644 --- a/docs/architecture/agent-preset-foundation.md +++ b/docs/architecture/agent-preset-foundation.md @@ -28,9 +28,19 @@ Foundation fields: - `provider_config_id` - `model` - `memory_config_json` stores `AgentMemoryConfig` as a JSON blob +- `persistent_skill_storage_enabled` reserves a default-off runtime enablement flag for future persistent skill retrieval - `created_at` - `updated_at` +Persistent agent skill storage is modeled separately from memories, knowledge documents, project workspaces, and model attachments: + +- `skill_storages` stores named, project-owned storage containers for reusable agent skills. +- `skills` stores individual skill records under a storage container, with content metadata and source identity. +- `skill_embeddings` stores embedding metadata and optional embedding blobs for skill search. +- `agent_skill_storage_bindings` attaches agent presets to one or more storage containers through a normalized `(agent_preset_id, storage_id)` binding. + +The shared preset contract exposes `persistentSkillStorageIds?: string[]` plus optional `persistentSkillStorage` enablement metadata. The repository round-trips those IDs through `agent_skill_storage_bindings`; it does not use a workspace path field for skill attachment state. Runtime mounting, provider prompt injection, MCP tools, and dashboard controls are intentionally not implemented in this foundation slice. + The current markdown-sync and Planning agent extensions are documented in: - [Agent Sync And Planning Agent](./agent-sync-and-planning-agent.md) @@ -68,6 +78,7 @@ Foundation-supported fields: - optional provider instance preference - optional model override - optional per-agent memory injection configuration +- optional persistent skill storage attachments (contract and storage only; no dashboard controls or runtime retrieval yet) The memory injection configuration is stored in sqlite as `memory_config_json` and parsed back into `AgentMemoryConfig` on reads, matching the existing JSON-column pattern used by `mcp_access_json`. The dashboard editor now initializes that config from the preset, exposes it through a dedicated `Manage Memory` popover, and persists the chosen filters alongside the rest of the preset payload. diff --git a/docs/settings/configuration-and-storage.md b/docs/settings/configuration-and-storage.md index b2fa3f9703..8b203b5d1d 100644 --- a/docs/settings/configuration-and-storage.md +++ b/docs/settings/configuration-and-storage.md @@ -59,6 +59,7 @@ Storage: - includes project planning tables (sprints with `original_prompt` and `goal`) plus sprint-scoped runtime projection in `app_settings`, `task_runs`, and `task_run_events` - runtime context rows are keyed by sprint (`runtime_context::`); legacy unscoped project-level runtime rows are deprecated and are no longer used for explicit sprint reads or rerun context - also stores sprint preview runtime state in `sprint_preview_sessions` + - persistent agent skill storage uses separate `skill_storages`, `skills`, `skill_embeddings`, and `agent_skill_storage_bindings` tables. These are distinct from project workspaces, `memories`, and `knowledge_documents`; agent presets attach to named storage records through normalized bindings rather than by storing workspace paths on the preset row. Runtime resolution: - effective runtime settings always resolve as `system -> project -> sprint` @@ -169,6 +170,7 @@ System-level integrations are injected into effective dashboard settings at reso - `git.githubToken` and `git.gitlabToken` are system-scoped - runtime fields like `dashboardPort`, `consoleLogLevel`, `debugLogFileLevel`, and `consoleLogMode` are system-scoped - project and sprint scopes still own `cliWorkflow.containerMountGithubAuth`, `cliWorkflow.containerGithubAuthPath`, `cliWorkflow.containerMountGitConfig`, `cliWorkflow.containerGitUserName`, and `cliWorkflow.containerGitUserEmail` +- `agents.selfReflection` is default-off for both `planning` and `qualityAssurance`. Each loop stores an `enabled` flag, senior engineering criteria with per-criterion thresholds, and `maxImprovementAttempts`; sanitization dedupes criteria by id, clamps thresholds to `0..1`, clamps attempts to `0..10`, and falls back to default criteria for malformed legacy payloads. These settings are contracts only in this phase; no provider reflection calls or dashboard controls are wired yet. Backend contract: - `src/contracts/app-types.ts` diff --git a/src/contracts/agent-preset-types.ts b/src/contracts/agent-preset-types.ts index a0db5b8d6c..624ff9de70 100644 --- a/src/contracts/agent-preset-types.ts +++ b/src/contracts/agent-preset-types.ts @@ -54,6 +54,11 @@ export interface AgentMemoryConfig { maxLongTerm: number; } +export interface AgentPersistentSkillStorageConfig { + /** Default-off runtime flag reserved for future skill retrieval wiring. */ + enabled: boolean; +} + export interface AgentPresetRecord { id: string; projectId: string; @@ -75,6 +80,8 @@ export interface AgentPresetRecord { memoryConfig?: AgentMemoryConfig; /** Per-agent MCP access config. Undefined for agents that have never been configured. */ mcpAccess?: AgentMcpAccessConfig; + persistentSkillStorageIds?: string[]; + persistentSkillStorage?: AgentPersistentSkillStorageConfig; createdAt: string; updatedAt: string; } @@ -92,6 +99,8 @@ export interface CreateAgentPresetInput { memoryTemplateMarkdown?: string; memoryConfig?: AgentMemoryConfig; mcpAccess?: AgentMcpAccessConfig; + persistentSkillStorageIds?: string[]; + persistentSkillStorage?: AgentPersistentSkillStorageConfig; } export interface UpdateAgentPresetInput { @@ -106,4 +115,6 @@ export interface UpdateAgentPresetInput { memoryTemplateMarkdown?: string; memoryConfig?: AgentMemoryConfig; mcpAccess?: AgentMcpAccessConfig; + persistentSkillStorageIds?: string[]; + persistentSkillStorage?: AgentPersistentSkillStorageConfig; } diff --git a/src/contracts/app-types.ts b/src/contracts/app-types.ts index 338d4b3d1e..29baaaf7c2 100644 --- a/src/contracts/app-types.ts +++ b/src/contracts/app-types.ts @@ -943,6 +943,24 @@ export interface QualityAssuranceSettings { completedTaskWithoutPr: QualityAssuranceTriggerSettings; } +export interface AgentSelfReflectionCriterionSettings { + id: string; + label: string; + prompt: string; + threshold: number; +} + +export interface AgentSelfReflectionLoopSettings { + enabled: boolean; + criteria: AgentSelfReflectionCriterionSettings[]; + maxImprovementAttempts: number; +} + +export interface AgentSelfReflectionSettings { + planning: AgentSelfReflectionLoopSettings; + qualityAssurance: AgentSelfReflectionLoopSettings; +} + export interface CodingAgentRoutingSettings { mode: AgentRoutingMode; agentPresetId: string | null; @@ -967,6 +985,7 @@ export interface AgentSettings { routing: AgentRoutingSettings; instructionTemplates: Record; qualityAssurance: QualityAssuranceSettings; + selfReflection: AgentSelfReflectionSettings; } export type BackgroundPattern = "NONE" | "DIAGONAL_LINES" | "HORIZONTAL_LINES" | "VERTICAL_LINES" | "CROSSHATCH" | "DOTS" | "DIAMONDS" | "HEXAGONS" | "TRIANGLES" | "WAVES" | "NOISE"; @@ -1014,6 +1033,56 @@ export interface CustomMcpServer { providers?: ProviderId[]; } +export type SkillStorageKind = "project" | "shared"; +export type SkillSourceType = "manual" | "imported" | "generated"; + +export interface SkillStorageRecord { + id: string; + projectId: string; + name: string; + description: string; + storageKind: SkillStorageKind; + createdAt: string; + updatedAt: string; +} + +export interface SkillRecord { + id: string; + projectId: string; + storageId: string; + name: string; + description: string; + contentMarkdown: string; + sourceType: SkillSourceType; + sourceRef: string | null; + contentHash: string; + tags: string[]; + createdAt: string; + updatedAt: string; +} + +export interface SkillEmbeddingMetadata { + id: string; + projectId: string; + storageId: string; + skillId: string; + embeddingModel: string; + embeddingDimension: number; + chunkIndex: number; + contentHash: string; + createdAt: string; + updatedAt: string; +} + +export interface AgentSkillStorageAttachment { + agentPresetId: string; + storageId: string; + projectId: string; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + export type RuntimeLogLevel = "off" | "debug" | "info" | "warn" | "error"; export type ConsoleLogMode = "standard" | "full"; export type RestartSprintPolicy = "continue" | "pause" | "cancel"; diff --git a/src/repositories/agent-preset-repository.ts b/src/repositories/agent-preset-repository.ts index 4b46aa35f3..7dae10f22d 100644 --- a/src/repositories/agent-preset-repository.ts +++ b/src/repositories/agent-preset-repository.ts @@ -27,6 +27,7 @@ interface AgentPresetRow { model: string | null; memory_template_override_enabled: number; memory_template_markdown: string | null; + persistent_skill_storage_enabled: number; mcp_access_json: string | null; memory_config_json: string | null; created_at: string; @@ -168,11 +169,12 @@ export class AgentPresetRepository { model, memory_template_override_enabled, memory_template_markdown, + persistent_skill_storage_enabled, memory_config_json, mcp_access_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, projectId, @@ -189,11 +191,13 @@ export class AgentPresetRepository { input.model?.trim() || null, input.memoryTemplateOverrideEnabled ? 1 : 0, input.memoryTemplateMarkdown || null, + input.persistentSkillStorage?.enabled ? 1 : 0, serializeMemoryConfig(input.memoryConfig), this.serializeMcpAccess(input.mcpAccess), now, now, ); + this.replacePersistentSkillStorageBindings(id, projectId, input.persistentSkillStorageIds, now); return requireRecord(this.getAgentPreset(id), "Agent preset", id); } @@ -221,11 +225,12 @@ export class AgentPresetRepository { model, memory_template_override_enabled, memory_template_markdown, + persistent_skill_storage_enabled, memory_config_json, mcp_access_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, projectId, @@ -242,11 +247,13 @@ export class AgentPresetRepository { input.model?.trim() || null, input.memoryTemplateOverrideEnabled ? 1 : 0, input.memoryTemplateMarkdown || null, + input.persistentSkillStorage?.enabled ? 1 : 0, input.memoryConfig ? JSON.stringify(input.memoryConfig) : null, this.serializeMcpAccess(input.mcpAccess), now, now, ); + this.replacePersistentSkillStorageBindings(id, projectId, input.persistentSkillStorageIds, now); return requireRecord(this.getAgentPreset(id), "Agent preset", id); } @@ -256,7 +263,7 @@ export class AgentPresetRepository { const now = new Date().toISOString(); this.db.prepare(` UPDATE agent_presets - SET name = ?, description = ?, instruction_markdown = ?, labels_json = ?, avatar_config_json = ?, provider_config_id = ?, model = ?, memory_template_override_enabled = ?, memory_template_markdown = ?, memory_config_json = ?, mcp_access_json = ?, updated_at = ? + SET name = ?, description = ?, instruction_markdown = ?, labels_json = ?, avatar_config_json = ?, provider_config_id = ?, model = ?, memory_template_override_enabled = ?, memory_template_markdown = ?, persistent_skill_storage_enabled = ?, memory_config_json = ?, mcp_access_json = ?, updated_at = ? WHERE id = ? `).run( input.name?.trim() || current.name, @@ -270,6 +277,7 @@ export class AgentPresetRepository { input.model === undefined ? current.model || null : input.model?.trim() || null, input.memoryTemplateOverrideEnabled === undefined ? (current.memoryTemplateOverrideEnabled ? 1 : 0) : (input.memoryTemplateOverrideEnabled ? 1 : 0), input.memoryTemplateMarkdown === undefined ? (current.memoryTemplateMarkdown || null) : (input.memoryTemplateMarkdown || null), + input.persistentSkillStorage === undefined ? (current.persistentSkillStorage?.enabled ? 1 : 0) : (input.persistentSkillStorage.enabled ? 1 : 0), input.memoryConfig === undefined ? (current.memoryConfig ? JSON.stringify(current.memoryConfig) : null) : (input.memoryConfig ? JSON.stringify(input.memoryConfig) : null), @@ -277,6 +285,9 @@ export class AgentPresetRepository { now, agentPresetId, ); + if (input.persistentSkillStorageIds !== undefined) { + this.replacePersistentSkillStorageBindings(agentPresetId, current.projectId, input.persistentSkillStorageIds, now); + } return requireRecord(this.getAgentPreset(agentPresetId), "Agent preset", agentPresetId); } @@ -433,6 +444,8 @@ export class AgentPresetRepository { memoryTemplateMarkdown: row.memory_template_markdown || undefined, memoryConfig: parseMemoryConfig(row.memory_config_json), mcpAccess: parseMcpAccess(row.mcp_access_json), + persistentSkillStorageIds: this.listPersistentSkillStorageIds(row.id), + persistentSkillStorage: { enabled: Boolean(row.persistent_skill_storage_enabled) }, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -458,6 +471,56 @@ export class AgentPresetRepository { return normalized; } + private normalizeStorageIds(storageIds?: string[]): string[] { + const seen = new Set(); + const normalized: string[] = []; + for (const storageId of storageIds || []) { + const trimmed = String(storageId || "").trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + normalized.push(trimmed); + } + return normalized; + } + + private listPersistentSkillStorageIds(agentPresetId: string): string[] { + const rows = this.db.prepare(` + SELECT storage_id + FROM agent_skill_storage_bindings + WHERE agent_preset_id = ? + AND enabled = 1 + ORDER BY created_at ASC, storage_id ASC + `).all(agentPresetId) as Array<{ storage_id: string }>; + return rows.map((row) => row.storage_id); + } + + private replacePersistentSkillStorageBindings( + agentPresetId: string, + projectId: string, + storageIds: string[] | undefined, + now: string, + ): void { + const normalized = this.normalizeStorageIds(storageIds); + this.db.prepare(` + DELETE FROM agent_skill_storage_bindings + WHERE agent_preset_id = ? + `).run(agentPresetId); + for (const storageId of normalized) { + this.db.prepare(` + INSERT INTO agent_skill_storage_bindings ( + agent_preset_id, + storage_id, + project_id, + enabled, + created_at, + updated_at + ) VALUES (?, ?, ?, 1, ?, ?) + `).run(agentPresetId, storageId, projectId, now, now); + } + } + private parseSourceScope(value: string | null): AgentSourceScope | null { if (value === "project" || value === "home" || value === "default") { return value; diff --git a/src/repositories/db/app-db-migrations.ts b/src/repositories/db/app-db-migrations.ts index f43c3ebd6e..d83b50de16 100644 --- a/src/repositories/db/app-db-migrations.ts +++ b/src/repositories/db/app-db-migrations.ts @@ -196,6 +196,7 @@ export function runMigrations(db: DatabaseAdapter): void { ensureColumn(db, "agent_presets", "memory_template_markdown", "TEXT"); ensureColumn(db, "agent_presets", "mcp_access_json", "TEXT"); ensureColumn(db, "agent_presets", "memory_config_json", "TEXT"); + ensureColumn(db, "agent_presets", "persistent_skill_storage_enabled", "INTEGER NOT NULL DEFAULT 0"); ensureColumn(db, "connection_project_bindings", "last_attention_cursor", "TEXT"); ensureColumn(db, "connection_project_bindings", "last_assignment_cursor", "TEXT"); @@ -345,6 +346,77 @@ export function runMigrations(db: DatabaseAdapter): void { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_claims_project_fingerprint_active ON memory_claims (project_id, fingerprint) WHERE status = 'active'"); ensureIndex(db, "idx_agent_presets_project_updated", "agent_presets", "project_id, updated_at DESC"); ensureIndex(db, "idx_agent_presets_project_name", "agent_presets", "project_id, name"); + db.exec(` + CREATE TABLE IF NOT EXISTS skill_storages ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + storage_kind TEXT NOT NULL DEFAULT 'project', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + UNIQUE (project_id, name) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS skills ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + storage_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + content_markdown TEXT NOT NULL DEFAULT '', + source_type TEXT NOT NULL DEFAULT 'manual', + source_ref TEXT, + content_hash TEXT NOT NULL, + tags_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (storage_id) REFERENCES skill_storages(id) ON DELETE CASCADE, + UNIQUE (storage_id, name) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS skill_embeddings ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + storage_id TEXT NOT NULL, + skill_id TEXT NOT NULL, + embedding_model TEXT NOT NULL, + embedding_dimension INTEGER NOT NULL, + chunk_index INTEGER NOT NULL DEFAULT 0, + content_hash TEXT NOT NULL, + embedding_blob BLOB, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (storage_id) REFERENCES skill_storages(id) ON DELETE CASCADE, + FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE, + UNIQUE (skill_id, embedding_model, chunk_index) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS agent_skill_storage_bindings ( + agent_preset_id TEXT NOT NULL, + storage_id TEXT NOT NULL, + project_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (agent_preset_id, storage_id), + FOREIGN KEY (agent_preset_id) REFERENCES agent_presets(id) ON DELETE CASCADE, + FOREIGN KEY (storage_id) REFERENCES skill_storages(id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE + ) + `); + ensureIndex(db, "idx_skill_storages_project", "skill_storages", "project_id, updated_at DESC"); + ensureIndex(db, "idx_skills_project_storage", "skills", "project_id, storage_id, updated_at DESC"); + ensureIndex(db, "idx_skill_embeddings_skill", "skill_embeddings", "skill_id, embedding_model, chunk_index"); + ensureIndex(db, "idx_skill_embeddings_storage", "skill_embeddings", "project_id, storage_id, embedding_model"); + ensureIndex(db, "idx_agent_skill_storage_bindings_agent", "agent_skill_storage_bindings", "agent_preset_id"); + ensureIndex(db, "idx_agent_skill_storage_bindings_storage", "agent_skill_storage_bindings", "project_id, storage_id"); ensureUniqueIndex(db, "idx_worker_endpoints_connection", "worker_endpoints", "connection_id"); ensureIndex(db, "idx_worker_endpoints_type_status", "worker_endpoints", "endpoint_type, status, updated_at DESC"); ensureIndex(db, "idx_connection_project_bindings_connection_active", "connection_project_bindings", "connection_id, is_active DESC, project_id ASC"); diff --git a/src/repositories/db/app-db-schema.ts b/src/repositories/db/app-db-schema.ts index 002ce59de9..e3e8cb707b 100644 --- a/src/repositories/db/app-db-schema.ts +++ b/src/repositories/db/app-db-schema.ts @@ -367,12 +367,74 @@ CREATE TABLE IF NOT EXISTS agent_presets ( model TEXT, memory_template_override_enabled INTEGER NOT NULL DEFAULT 0, memory_template_markdown TEXT, + persistent_skill_storage_enabled INTEGER NOT NULL DEFAULT 0, mcp_access_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS skill_storages ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + storage_kind TEXT NOT NULL DEFAULT 'project', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + UNIQUE (project_id, name) + ); + +CREATE TABLE IF NOT EXISTS skills ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + storage_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + content_markdown TEXT NOT NULL DEFAULT '', + source_type TEXT NOT NULL DEFAULT 'manual', + source_ref TEXT, + content_hash TEXT NOT NULL, + tags_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (storage_id) REFERENCES skill_storages(id) ON DELETE CASCADE, + UNIQUE (storage_id, name) + ); + +CREATE TABLE IF NOT EXISTS skill_embeddings ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + storage_id TEXT NOT NULL, + skill_id TEXT NOT NULL, + embedding_model TEXT NOT NULL, + embedding_dimension INTEGER NOT NULL, + chunk_index INTEGER NOT NULL DEFAULT 0, + content_hash TEXT NOT NULL, + embedding_blob BLOB, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (storage_id) REFERENCES skill_storages(id) ON DELETE CASCADE, + FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE, + UNIQUE (skill_id, embedding_model, chunk_index) + ); + +CREATE TABLE IF NOT EXISTS agent_skill_storage_bindings ( + agent_preset_id TEXT NOT NULL, + storage_id TEXT NOT NULL, + project_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (agent_preset_id, storage_id), + FOREIGN KEY (agent_preset_id) REFERENCES agent_presets(id) ON DELETE CASCADE, + FOREIGN KEY (storage_id) REFERENCES skill_storages(id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS sprint_runs ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, @@ -714,4 +776,10 @@ CREATE INDEX IF NOT EXISTS idx_execution_invocations_project_sprint_started ON e CREATE INDEX IF NOT EXISTS idx_execution_invocations_project_sprint_run_started ON execution_invocations (project_id, sprint_run_id, started_at DESC); CREATE INDEX IF NOT EXISTS idx_execution_invocations_status_started ON execution_invocations (status, started_at DESC); CREATE INDEX IF NOT EXISTS idx_execution_invocations_provider_invocation ON execution_invocations (provider_invocation_id); +CREATE INDEX IF NOT EXISTS idx_skill_storages_project ON skill_storages (project_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_skills_project_storage ON skills (project_id, storage_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_skill_embeddings_skill ON skill_embeddings (skill_id, embedding_model, chunk_index); +CREATE INDEX IF NOT EXISTS idx_skill_embeddings_storage ON skill_embeddings (project_id, storage_id, embedding_model); +CREATE INDEX IF NOT EXISTS idx_agent_skill_storage_bindings_agent ON agent_skill_storage_bindings (agent_preset_id); +CREATE INDEX IF NOT EXISTS idx_agent_skill_storage_bindings_storage ON agent_skill_storage_bindings (project_id, storage_id); `; diff --git a/src/repositories/settings-defaults.ts b/src/repositories/settings-defaults.ts index 0344abbf6f..1743d28967 100644 --- a/src/repositories/settings-defaults.ts +++ b/src/repositories/settings-defaults.ts @@ -257,6 +257,70 @@ export const QA_EXHAUSTION_POLICIES: QaExhaustionPolicy[] = [ "FAIL_TASK", "FINISH_TASK", ]; + +const DEFAULT_SELF_REFLECTION_CRITERIA: DashboardSettings["agents"]["selfReflection"]["planning"]["criteria"] = [ + { + id: "correctness", + label: "Correctness", + prompt: "The plan or review accurately addresses the requested behavior and repository facts.", + threshold: 0.85, + }, + { + id: "completeness", + label: "Completeness", + prompt: "The response covers all required deliverables, edge cases, and verification expectations.", + threshold: 0.85, + }, + { + id: "decomposition_quality", + label: "Decomposition quality", + prompt: "Work is broken into coherent, dependency-aware steps with clear ownership boundaries.", + threshold: 0.8, + }, + { + id: "risk_handling", + label: "Risk handling", + prompt: "Important technical, operational, and rollback risks are identified and handled.", + threshold: 0.8, + }, + { + id: "testability", + label: "Testability", + prompt: "The proposed work can be validated with focused deterministic checks.", + threshold: 0.8, + }, + { + id: "maintainability", + label: "Maintainability", + prompt: "The approach preserves local architecture and avoids unnecessary complexity.", + threshold: 0.8, + }, + { + id: "security", + label: "Security", + prompt: "The approach avoids weakening validation, secrets handling, permissions, and auditability.", + threshold: 0.85, + }, + { + id: "scope_control", + label: "Scope control", + prompt: "The work stays within the task contract and avoids unrelated behavior changes.", + threshold: 0.85, + }, +]; + +export const DEFAULT_AGENT_SELF_REFLECTION: DashboardSettings["agents"]["selfReflection"] = { + planning: { + enabled: false, + criteria: DEFAULT_SELF_REFLECTION_CRITERIA.map((criterion) => ({ ...criterion })), + maxImprovementAttempts: 1, + }, + qualityAssurance: { + enabled: false, + criteria: DEFAULT_SELF_REFLECTION_CRITERIA.map((criterion) => ({ ...criterion })), + maxImprovementAttempts: 1, + }, +}; /** Fallback cap used when migrating the legacy hardcoded clarification auto-answer limit. */ export const LEGACY_CLARIFICATION_RETRY_CAP = 3; @@ -626,6 +690,18 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = { agentPresetId: null, }, }, + selfReflection: { + planning: { + enabled: DEFAULT_AGENT_SELF_REFLECTION.planning.enabled, + criteria: DEFAULT_AGENT_SELF_REFLECTION.planning.criteria.map((criterion) => ({ ...criterion })), + maxImprovementAttempts: DEFAULT_AGENT_SELF_REFLECTION.planning.maxImprovementAttempts, + }, + qualityAssurance: { + enabled: DEFAULT_AGENT_SELF_REFLECTION.qualityAssurance.enabled, + criteria: DEFAULT_AGENT_SELF_REFLECTION.qualityAssurance.criteria.map((criterion) => ({ ...criterion })), + maxImprovementAttempts: DEFAULT_AGENT_SELF_REFLECTION.qualityAssurance.maxImprovementAttempts, + }, + }, }, skills: DEFAULT_SKILLS, mcpTools: DEFAULT_MCP_TOOL_TOGGLES.map((tool) => ({ ...tool })), diff --git a/src/repositories/settings-sanitizer.ts b/src/repositories/settings-sanitizer.ts index ba64c2cc8f..1c148dce11 100644 --- a/src/repositories/settings-sanitizer.ts +++ b/src/repositories/settings-sanitizer.ts @@ -27,6 +27,7 @@ import { } from "../domain/settings/provider-config-utils.js"; import { DEFAULT_DASHBOARD_SETTINGS, + DEFAULT_AGENT_SELF_REFLECTION, DEFAULT_SKILLS, INTERNAL_SKILL_NAMES, INTERNAL_SKILL_SET, @@ -221,6 +222,73 @@ const sanitizeQualityAssurance = ( }; }; +const SELF_REFLECTION_MAX_ATTEMPTS_CEILING = 10; + +const sanitizeSelfReflectionCriteria = ( + value: unknown, + defaults: DashboardSettings["agents"]["selfReflection"]["planning"]["criteria"], +): DashboardSettings["agents"]["selfReflection"]["planning"]["criteria"] => { + if (!Array.isArray(value)) { + return defaults.map((criterion) => ({ ...criterion })); + } + + const criteria: DashboardSettings["agents"]["selfReflection"]["planning"]["criteria"] = []; + const seen = new Set(); + for (const entry of value) { + if (!entry || typeof entry !== "object") { + continue; + } + const input = entry as Record; + const id = readString(input.id, "").trim(); + const label = readString(input.label, "").trim(); + const prompt = readString(input.prompt, "").trim(); + if (!id || !label || !prompt || seen.has(id)) { + continue; + } + const rawThreshold = typeof input.threshold === "number" && Number.isFinite(input.threshold) + ? input.threshold + : defaults.find((criterion) => criterion.id === id)?.threshold ?? 0.8; + criteria.push({ + id, + label, + prompt, + threshold: Math.max(0, Math.min(1, rawThreshold)), + }); + seen.add(id); + } + + return criteria.length > 0 ? criteria : defaults.map((criterion) => ({ ...criterion })); +}; + +const sanitizeSelfReflectionLoop = ( + value: unknown, + defaults: DashboardSettings["agents"]["selfReflection"]["planning"], +): DashboardSettings["agents"]["selfReflection"]["planning"] => { + const input = value && typeof value === "object" ? value as Record : {}; + const maxImprovementAttempts = typeof input.maxImprovementAttempts === "number" && Number.isFinite(input.maxImprovementAttempts) + ? Math.max(0, Math.min(SELF_REFLECTION_MAX_ATTEMPTS_CEILING, Math.round(input.maxImprovementAttempts))) + : defaults.maxImprovementAttempts; + + return { + enabled: readBoolean(input.enabled, defaults.enabled), + criteria: sanitizeSelfReflectionCriteria(input.criteria, defaults.criteria), + maxImprovementAttempts, + }; +}; + +const sanitizeSelfReflection = ( + value: unknown, +): DashboardSettings["agents"]["selfReflection"] => { + const input = value && typeof value === "object" ? value as Record : {}; + return { + planning: sanitizeSelfReflectionLoop(input.planning, DEFAULT_AGENT_SELF_REFLECTION.planning), + qualityAssurance: sanitizeSelfReflectionLoop( + input.qualityAssurance, + DEFAULT_AGENT_SELF_REFLECTION.qualityAssurance, + ), + }; +}; + const cloneAgentRouting = (): DashboardSettings["agents"]["routing"] => ({ planning: { ...DEFAULT_DASHBOARD_SETTINGS.agents.routing.planning }, taskCoding: { @@ -340,6 +408,7 @@ export const cloneDefaults = (externalHints?: ExternalSettingsHints): DashboardS agentPresetIds: [...DEFAULT_DASHBOARD_SETTINGS.agents.qualityAssurance.completedTaskWithoutPr.agentPresetIds], }, }, + selfReflection: sanitizeSelfReflection(DEFAULT_DASHBOARD_SETTINGS.agents.selfReflection), }, skills: DEFAULT_DASHBOARD_SETTINGS.skills.map((skill) => ({ ...skill })), mcpTools: DEFAULT_DASHBOARD_SETTINGS.mcpTools.map((tool) => ({ ...tool })), @@ -518,6 +587,7 @@ export const sanitizeSettings = (value: unknown, externalHints?: ExternalSetting qualityAssurance: sanitizeQualityAssurance( agentsInput.qualityAssurance as Partial | undefined, ), + selfReflection: sanitizeSelfReflection(agentsInput.selfReflection), }; const normalizedSkills = enforceGitManagerSkillset(sanitizeSkills(input.skills), git.githubMode); diff --git a/src/services/settings-resolution-service.ts b/src/services/settings-resolution-service.ts index 589d2a7ed8..16a2fd26cb 100644 --- a/src/services/settings-resolution-service.ts +++ b/src/services/settings-resolution-service.ts @@ -36,7 +36,7 @@ import { } from "../domain/settings/provider-config-utils.js"; import { sanitizeCustomMcpServersWithDefaults, sanitizeMcpToolToggles } from "../mcp/mcp-tool-availability.js"; import { DEFAULT_INSTRUCTION_TEMPLATES, INSTRUCTION_TEMPLATE_IDS, type InstructionTemplateId } from "../instructions/instruction-template-catalog.js"; -import { DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SKILLS, INTERNAL_SKILL_NAMES, INTERNAL_SKILL_SET } from "../repositories/settings-defaults.js"; +import { DEFAULT_AGENT_SELF_REFLECTION, DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SKILLS, INTERNAL_SKILL_NAMES, INTERNAL_SKILL_SET } from "../repositories/settings-defaults.js"; function cloneSkills(skills: SkillToggle[]): SkillToggle[] { return skills.map((skill) => ({ ...skill })); @@ -168,6 +168,23 @@ function cloneQualityAssuranceSettings( }; } +function cloneSelfReflectionSettings( + settings: ProjectSettings["agents"]["selfReflection"], +): ProjectSettings["agents"]["selfReflection"] { + return { + planning: { + enabled: settings.planning.enabled, + criteria: settings.planning.criteria.map((criterion) => ({ ...criterion })), + maxImprovementAttempts: settings.planning.maxImprovementAttempts, + }, + qualityAssurance: { + enabled: settings.qualityAssurance.enabled, + criteria: settings.qualityAssurance.criteria.map((criterion) => ({ ...criterion })), + maxImprovementAttempts: settings.qualityAssurance.maxImprovementAttempts, + }, + }; +} + function cloneAgentRoutingSettings( settings: ProjectSettings["agents"]["routing"], ): ProjectSettings["agents"]["routing"] { @@ -428,6 +445,65 @@ function sanitizeQualityAssuranceSettings( }; } +const SELF_REFLECTION_MAX_ATTEMPTS_CEILING = 10; + +function sanitizeSelfReflectionCriteria( + value: unknown, + defaults: ProjectSettings["agents"]["selfReflection"]["planning"]["criteria"], +): ProjectSettings["agents"]["selfReflection"]["planning"]["criteria"] { + if (!Array.isArray(value)) { + return defaults.map((criterion) => ({ ...criterion })); + } + + const criteria: ProjectSettings["agents"]["selfReflection"]["planning"]["criteria"] = []; + const seen = new Set(); + for (const entry of value) { + const input = toRecord(entry); + const id = typeof input.id === "string" ? input.id.trim() : ""; + const label = typeof input.label === "string" ? input.label.trim() : ""; + const prompt = typeof input.prompt === "string" ? input.prompt.trim() : ""; + if (!id || !label || !prompt || seen.has(id)) { + continue; + } + const rawThreshold = typeof input.threshold === "number" && Number.isFinite(input.threshold) + ? input.threshold + : defaults.find((criterion) => criterion.id === id)?.threshold ?? 0.8; + criteria.push({ + id, + label, + prompt, + threshold: Math.max(0, Math.min(1, rawThreshold)), + }); + seen.add(id); + } + + return criteria.length > 0 ? criteria : defaults.map((criterion) => ({ ...criterion })); +} + +function sanitizeSelfReflectionLoop( + value: unknown, + defaults: ProjectSettings["agents"]["selfReflection"]["planning"], +): ProjectSettings["agents"]["selfReflection"]["planning"] { + const input = toRecord(value); + return { + enabled: typeof input.enabled === "boolean" ? input.enabled : defaults.enabled, + criteria: sanitizeSelfReflectionCriteria(input.criteria, defaults.criteria), + maxImprovementAttempts: typeof input.maxImprovementAttempts === "number" && Number.isFinite(input.maxImprovementAttempts) + ? Math.max(0, Math.min(SELF_REFLECTION_MAX_ATTEMPTS_CEILING, Math.round(input.maxImprovementAttempts))) + : defaults.maxImprovementAttempts, + }; +} + +function sanitizeSelfReflectionSettings( + value: unknown, +): ProjectSettings["agents"]["selfReflection"] { + const input = toRecord(value); + return { + planning: sanitizeSelfReflectionLoop(input.planning, DEFAULT_AGENT_SELF_REFLECTION.planning), + qualityAssurance: sanitizeSelfReflectionLoop(input.qualityAssurance, DEFAULT_AGENT_SELF_REFLECTION.qualityAssurance), + }; +} + function sanitizeSprintPreviewSettings(value: unknown): ProjectSettings["sprintPreview"] { const input = toRecord(value); const defaults = DEFAULT_DASHBOARD_SETTINGS.sprintPreview; @@ -550,6 +626,7 @@ export function buildDefaultProjectSettings(externalHints?: ExternalSettingsHint routing: cloneAgentRoutingSettings(DEFAULT_DASHBOARD_SETTINGS.agents.routing), instructionTemplates: cloneInstructionTemplates(DEFAULT_DASHBOARD_SETTINGS.agents.instructionTemplates), qualityAssurance: cloneQualityAssuranceSettings(DEFAULT_DASHBOARD_SETTINGS.agents.qualityAssurance), + selfReflection: cloneSelfReflectionSettings(DEFAULT_DASHBOARD_SETTINGS.agents.selfReflection), }, skills: cloneSkills(DEFAULT_SKILLS), memory: { @@ -704,6 +781,7 @@ export function sanitizeProjectSettings(value: unknown, externalHints?: External routing: sanitizeAgentRoutingSettings(toRecord(input.agents).routing), instructionTemplates: sanitizeInstructionTemplates(toRecord(input.agents).instructionTemplates), qualityAssurance: sanitizeQualityAssuranceSettings(toRecord(input.agents).qualityAssurance), + selfReflection: sanitizeSelfReflectionSettings(toRecord(input.agents).selfReflection), }, skills: sanitizeSkills(input.skills, git.githubMode), ...(Array.isArray(input.mcpTools) ? { mcpTools: sanitizeMcpToolToggles(input.mcpTools) } : {}), @@ -1072,6 +1150,7 @@ export function resolveDashboardSettings(args: { routing: cloneAgentRoutingSettings(sprintSettings.agents.routing), instructionTemplates: cloneInstructionTemplates(sprintSettings.agents.instructionTemplates), qualityAssurance: cloneQualityAssuranceSettings(sprintSettings.agents.qualityAssurance), + selfReflection: cloneSelfReflectionSettings(sprintSettings.agents.selfReflection), }, skills: cloneSkills(sprintSettings.skills), mcpTools: resolveEffectiveMcpTools(args.systemSettings.mcpTools, sprintSettings.mcpTools), diff --git a/tests/backend/repositories/agent-preset-repository.test.ts b/tests/backend/repositories/agent-preset-repository.test.ts index 6d79da396a..1f12784d44 100644 --- a/tests/backend/repositories/agent-preset-repository.test.ts +++ b/tests/backend/repositories/agent-preset-repository.test.ts @@ -25,6 +25,26 @@ describe("AgentPresetRepository", () => { sourceType: "local", sourceRef: "/workspace/preset-project", }); + const now = new Date().toISOString(); + storage.getDatabase().prepare(` + INSERT INTO skill_storages (id, project_id, name, description, storage_kind, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?) + `).run( + "skills-core", + project.id, + "Core skills", + "Shared project skills.", + "project", + now, + now, + "skills-review", + project.id, + "Review skills", + "Review-specific skills.", + "project", + now, + now, + ); const created = agentPresetRepository.createAgentPreset(project.id, { name: "Project Manager", @@ -44,6 +64,8 @@ describe("AgentPresetRepository", () => { maxShortTerm: 3, maxLongTerm: 5, }, + persistentSkillStorageIds: ["skills-core", "skills-core", "skills-review", ""], + persistentSkillStorage: { enabled: true }, }); expect(created).toMatchObject({ @@ -65,6 +87,8 @@ describe("AgentPresetRepository", () => { maxShortTerm: 3, maxLongTerm: 5, }, + persistentSkillStorageIds: ["skills-core", "skills-review"], + persistentSkillStorage: { enabled: true }, }); const updated = agentPresetRepository.updateAgentPreset(created.id, { @@ -76,6 +100,8 @@ describe("AgentPresetRepository", () => { providerConfigId: null, model: "gpt-5.4", memoryTemplateOverrideEnabled: false, + persistentSkillStorageIds: ["skills-review"], + persistentSkillStorage: { enabled: false }, }); expect(updated).toMatchObject({ name: "Worker", @@ -95,6 +121,8 @@ describe("AgentPresetRepository", () => { maxShortTerm: 3, maxLongTerm: 5, }, + persistentSkillStorageIds: ["skills-review"], + persistentSkillStorage: { enabled: false }, }); const listed = agentPresetRepository.listAgentPresets(project.id); diff --git a/tests/backend/repositories/app-db-storage.test.ts b/tests/backend/repositories/app-db-storage.test.ts index 330978256d..e9ff25acc5 100644 --- a/tests/backend/repositories/app-db-storage.test.ts +++ b/tests/backend/repositories/app-db-storage.test.ts @@ -76,6 +76,10 @@ describe("AppDbStorage", () => { expect(storage.hasTable("conversation_threads")).toBe(true); expect(storage.hasTable("conversation_messages")).toBe(true); expect(storage.hasTable("agent_presets")).toBe(true); + expect(storage.hasTable("skill_storages")).toBe(true); + expect(storage.hasTable("skills")).toBe(true); + expect(storage.hasTable("skill_embeddings")).toBe(true); + expect(storage.hasTable("agent_skill_storage_bindings")).toBe(true); const db = storage.getDatabase(); const taskDispatchesIndexes = db.prepare("PRAGMA index_list('task_dispatches')").all() as Array<{ name: string }>; @@ -108,6 +112,11 @@ describe("AppDbStorage", () => { expect(getIndexColumns(db, "idx_provider_invocations_sprint_started")).toEqual(["sprint_id", "started_at"]); expect(getIndexColumns(db, "idx_provider_invocations_sprint_run_started")).toEqual(["sprint_run_id", "started_at"]); + const skillStorageIndexes = db.prepare("PRAGMA index_list('skill_storages')").all() as Array<{ name: string }>; + expect(skillStorageIndexes.some((idx) => idx.name === "idx_skill_storages_project")).toBe(true); + expect(getIndexColumns(db, "idx_agent_skill_storage_bindings_agent")).toEqual(["agent_preset_id"]); + expect(getIndexColumns(db, "idx_agent_skill_storage_bindings_storage")).toEqual(["project_id", "storage_id"]); + const taskRunEventIndexes = db.prepare("PRAGMA index_list('task_run_events')").all() as Array<{ name: string }>; expect(taskRunEventIndexes.some((idx) => idx.name === "idx_task_run_events_project_created")).toBe(true); expect(taskRunEventIndexes.some((idx) => idx.name === "idx_task_run_events_task_run_created_id")).toBe(true); diff --git a/tests/backend/repositories/settings-repository.test.ts b/tests/backend/repositories/settings-repository.test.ts index 29d61000c0..8dd048b349 100644 --- a/tests/backend/repositories/settings-repository.test.ts +++ b/tests/backend/repositories/settings-repository.test.ts @@ -64,6 +64,19 @@ describe("SettingsRepository", () => { expect(system.defaults.agents.qualityAssurance.sprintCompletion.agentPresetIds).toEqual([]); expect(system.defaults.agents.qualityAssurance.completedTaskWithoutPr.enabled).toBe(true); expect(system.defaults.agents.qualityAssurance.completedTaskWithoutPr.agentPresetIds).toEqual([]); + expect(system.defaults.agents.selfReflection.planning.enabled).toBe(false); + expect(system.defaults.agents.selfReflection.planning.maxImprovementAttempts).toBe(1); + expect(system.defaults.agents.selfReflection.planning.criteria.map((criterion) => criterion.id)).toEqual([ + "correctness", + "completeness", + "decomposition_quality", + "risk_handling", + "testability", + "maintainability", + "security", + "scope_control", + ]); + expect(system.defaults.agents.selfReflection.qualityAssurance.enabled).toBe(false); expect(system.defaults.agents.instructionTemplates.planningMissing).toContain("Sprint Planning Missing"); expect(system.mcpTools.length).toBeGreaterThan(0); @@ -191,6 +204,7 @@ describe("SettingsRepository", () => { agentPresetId: null, }, }, + selfReflection: repo.getSystemSettings().defaults.agents.selfReflection, }, skills: [ { name: "worker", enabled: true, isInternal: true }, @@ -313,6 +327,43 @@ describe("SettingsRepository", () => { enabled: false, }, }, + selfReflection: { + planning: { + enabled: true, + maxImprovementAttempts: 99, + criteria: [ + { + id: " correctness ", + label: " Correctness ", + prompt: " Check correctness. ", + threshold: 2, + }, + { + id: "correctness", + label: "Duplicate", + prompt: "Duplicate should be ignored.", + threshold: 0.1, + }, + { + id: "", + label: "Invalid", + prompt: "Missing id.", + threshold: 0.5, + }, + { + id: "scope_control", + label: "Scope control", + prompt: "Stay inside scope.", + threshold: -1, + }, + ], + }, + qualityAssurance: { + enabled: "yes", + maxImprovementAttempts: "many", + criteria: "invalid", + }, + }, }, }, }), now); @@ -340,6 +391,24 @@ describe("SettingsRepository", () => { expect(effectiveProject.settings.git.featureBranchPrefix).toBe("work/"); expect(effectiveProject.settings.agents.qualityAssurance.taskCompletion.enabled).toBe(false); expect(effectiveProject.settings.agents.qualityAssurance.maxTaskReviewRuns).toBe(3); + expect(effectiveProject.settings.agents.selfReflection.planning.enabled).toBe(true); + expect(effectiveProject.settings.agents.selfReflection.planning.maxImprovementAttempts).toBe(10); + expect(effectiveProject.settings.agents.selfReflection.planning.criteria).toEqual([ + { + id: "correctness", + label: "Correctness", + prompt: "Check correctness.", + threshold: 1, + }, + { + id: "scope_control", + label: "Scope control", + prompt: "Stay inside scope.", + threshold: 0, + }, + ]); + expect(effectiveProject.settings.agents.selfReflection.qualityAssurance.enabled).toBe(false); + expect(effectiveProject.settings.agents.selfReflection.qualityAssurance.criteria.length).toBeGreaterThan(1); expect(effectiveProject.sources["git.featureBranchPrefix"]).toBe("project"); const effectiveSprint = repo.resolveSprintDashboardSettings("project-partial", "sprint-partial"); diff --git a/tests/dashboard/lib/onboarding-provider-settings.test.ts b/tests/dashboard/lib/onboarding-provider-settings.test.ts index 16fceae267..2ce5795922 100644 --- a/tests/dashboard/lib/onboarding-provider-settings.test.ts +++ b/tests/dashboard/lib/onboarding-provider-settings.test.ts @@ -81,7 +81,19 @@ describe("onboarding-provider-settings", () => { taskCompletion: { strategy: "ALWAYS", agentPresetIds: [], agentPresetId: null }, sprintCompletion: { strategy: "ALWAYS", agentPresetIds: [], agentPresetId: null }, completedTaskWithoutPr: { strategy: "CREATE_PR", agentPresetIds: [], agentPresetId: null }, - } + }, + selfReflection: { + planning: { + enabled: false, + criteria: [{ id: "correctness", label: "Correctness", prompt: "Check correctness.", threshold: 0.8 }], + maxImprovementAttempts: 1, + }, + qualityAssurance: { + enabled: false, + criteria: [{ id: "security", label: "Security", prompt: "Check security.", threshold: 0.85 }], + maxImprovementAttempts: 1, + }, + }, }, guardrails: { onLimitAction: "WARN", defaultLimitOverrides: [], limitOverrides: [], jobConfigOverrides: [], jobs: { task_coding: {}, ci_fix: {}, merge_conflict: {}, clarification_reply: {}, planning: {}, remediation: {} } }, skills: [], diff --git a/tests/dashboard/lib/settings-view-models.test.ts b/tests/dashboard/lib/settings-view-models.test.ts index 0976f60e29..fe0f0b286f 100644 --- a/tests/dashboard/lib/settings-view-models.test.ts +++ b/tests/dashboard/lib/settings-view-models.test.ts @@ -751,6 +751,22 @@ describe("settings cloning helpers", () => { }, instructionTemplates: {}, qualityAssurance: createMockQualityAssurance(), + selfReflection: { + planning: { + enabled: false, + criteria: [ + { id: "correctness", label: "Correctness", prompt: "Check correctness.", threshold: 0.8 }, + ], + maxImprovementAttempts: 1, + }, + qualityAssurance: { + enabled: false, + criteria: [ + { id: "security", label: "Security", prompt: "Check security.", threshold: 0.85 }, + ], + maxImprovementAttempts: 1, + }, + }, }, skills: [{ id: "skill1", enabled: true }], mcpTools: [{ serverName: "s1", toolName: "t1", enabled: true }], @@ -778,6 +794,7 @@ describe("settings cloning helpers", () => { clone.agents.qualityAssurance.enabled = false; clone.agents.qualityAssurance.taskCompletion.strategy = "NEVER"; clone.agents.qualityAssurance.sprintCompletion.agentPresetIds.push("qa-extra"); + clone.agents.selfReflection.planning.criteria[0]!.threshold = 0.1; clone.agents.routing.taskCoding.orchestratorAgentPresetIds.push("c"); clone.customMcpServers![0].headers!["X-New"] = "123"; clone.customMcpServers![0].env!["BAZ"] = "qux"; @@ -791,6 +808,7 @@ describe("settings cloning helpers", () => { expect(original.agents.qualityAssurance.enabled).toBe(true); expect(original.agents.qualityAssurance.taskCompletion.strategy).toBe("ALWAYS"); expect(original.agents.qualityAssurance.sprintCompletion.agentPresetIds).toEqual(["qa-sprint", "qa-peer"]); + expect(original.agents.selfReflection.planning.criteria[0]!.threshold).toBe(0.8); expect(original.agents.routing.taskCoding.orchestratorAgentPresetIds).toEqual(["a", "b"]); expect(original.customMcpServers![0].headers!["X-New"]).toBeUndefined(); expect(original.customMcpServers![0].env!["BAZ"]).toBeUndefined(); @@ -809,6 +827,7 @@ describe("settings cloning helpers", () => { clone.jira.host = "new-host"; clone.agents.qualityAssurance.enabled = false; clone.agents.qualityAssurance.sprintCompletion.agentPresetIds.push("qa-extra"); + clone.agents.selfReflection.qualityAssurance.criteria.push({ id: "scope_control", label: "Scope control", prompt: "Stay scoped.", threshold: 0.8 }); clone.agents.routing.taskCoding.orchestratorAgentPresetIds.push("c"); clone.customMcpServers![0].headers!["X-New"] = "123"; @@ -817,6 +836,7 @@ describe("settings cloning helpers", () => { expect(original.jira.host).toBe("h"); expect(original.agents.qualityAssurance.enabled).toBe(true); expect(original.agents.qualityAssurance.sprintCompletion.agentPresetIds).toEqual(["qa-sprint", "qa-peer"]); + expect(original.agents.selfReflection.qualityAssurance.criteria).toHaveLength(1); expect(original.agents.routing.taskCoding.orchestratorAgentPresetIds).toEqual(["a", "b"]); expect(original.customMcpServers![0].headers!["X-New"]).toBeUndefined(); }); diff --git a/tests/dashboard/lib/settings.test.ts b/tests/dashboard/lib/settings.test.ts index f3f0ae90ee..6a1907883c 100644 --- a/tests/dashboard/lib/settings.test.ts +++ b/tests/dashboard/lib/settings.test.ts @@ -17,6 +17,8 @@ describe("dashboard settings helpers", () => { first.cliWorkflow.executionMode = "DOCKER"; first.cliWorkflow.containerImage = "custom:image"; first.cliWorkflow.containerCacheSetupScriptImage = true; + first.agents.selfReflection.planning.enabled = true; + first.agents.selfReflection.planning.criteria[0]!.threshold = 0.1; first.mcpTools[0].enabled = false; expect(second.git.defaultBranch).toBe("main"); expect(second.dashboardPort).toBe(4444); @@ -30,6 +32,8 @@ describe("dashboard settings helpers", () => { expect(second.cliWorkflow.executionMode).toBe("DOCKER"); expect(second.cliWorkflow.containerImage).toBe("node:24-bookworm"); expect(second.cliWorkflow.containerCacheSetupScriptImage).toBe(true); + expect(second.agents.selfReflection.planning.enabled).toBe(false); + expect(second.agents.selfReflection.planning.criteria[0]!.threshold).toBe(0.85); expect(second.mcpTools[0].enabled).toBe(true); }); From 3ebacf3b06df423e5573857bce0891e03272b92a Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 01:18:23 +0000 Subject: [PATCH 2/8] feat(task T05): implement via codex --- docs/architecture/quality-assurance-agent.md | 10 + docs/sprint-loop/atomic-loop.md | 4 + .../structured-agent-request-service.ts | 434 +++++++++++++++++- .../structured-provider-response-service.ts | 2 + .../domain/qa-review/qa-review-runner.test.ts | 34 ++ .../services/planning-json-extractor.test.ts | 57 ++- .../structured-agent-request-service.test.ts | 331 +++++++++++++ 7 files changed, 868 insertions(+), 4 deletions(-) diff --git a/docs/architecture/quality-assurance-agent.md b/docs/architecture/quality-assurance-agent.md index ea18d4a329..c992da840e 100644 --- a/docs/architecture/quality-assurance-agent.md +++ b/docs/architecture/quality-assurance-agent.md @@ -23,6 +23,7 @@ The QA agent is designed to: Project-scoped settings live under: - `agents.qualityAssurance` +- `agents.selfReflection.qualityAssurance` The current settings are: @@ -43,6 +44,14 @@ The current settings are: - `agentPresetIds` - `agentPresetId` legacy compatibility mirror +QA self-reflection is configured separately under `agents.selfReflection.qualityAssurance`: + +- `enabled`: defaults to `false`; when false, QA output is accepted exactly as it was before this loop existed +- `criteria`: ordered `{ id, label, prompt, threshold }` records, where `threshold` is stored on the 0-1 settings scale and compared against the reviewer self-rating converted from 1-10 +- `maxImprovementAttempts`: maximum number of same-session improvement prompts after a below-threshold rating + +When enabled, Code UX asks the same QA provider session to return JSON-only self-ratings for the normalized QA result. If every criterion meets its threshold, the original normalized result is used. If any criterion is below threshold and attempts remain, Code UX asks for an improved QA JSON payload and re-runs the normal QA schema normalization. Invalid reflection JSON, provider failures, or invalid improved QA JSON are fail-open for this optional loop: Code UX logs the issue and keeps the last valid normalized QA result. + Each QA trigger owns an ordered reviewer roster in `agentPresetIds`: - `[]` means no custom reviewer IDs are configured; Code UX resolves one built-in/default QA reviewer through `resolveTargetedQualityAssuranceAgent(projectId, null)`. @@ -82,6 +91,7 @@ Persistence: - `src/repositories/qa-review-repository.ts` - `qa_review_runs` table in `src/repositories/db/app-db-schema.ts` +- optional self-reflection ratings are recorded as `execution_invocation_messages.metadata_json` on the QA invocation, including criteria ids, labels, thresholds, scores, pass/fail state, attempt count, and final decision. Raw provider credentials are not stored in these metadata records. Provider routing: diff --git a/docs/sprint-loop/atomic-loop.md b/docs/sprint-loop/atomic-loop.md index 4bc36cc8cb..99f0919e33 100644 --- a/docs/sprint-loop/atomic-loop.md +++ b/docs/sprint-loop/atomic-loop.md @@ -98,6 +98,9 @@ If `action=plan`: - Optionally injects `sprint_agent_guide.md`. - Returns templated planning instructions. - Planning may apply a provider-suggested sprint title only when the sprint was explicitly stored as generated/auto-named at creation time. Placeholder-looking custom titles such as `Untitled sprint 1` are treated as user titles and are not writable by planning. +- Planning self-reflection is available under `agents.selfReflection.planning` and defaults to `enabled: false`. When enabled, the planning provider rates its parsed JSON output against configured `{ id, label, prompt, threshold }` criteria using JSON-only 1-10 scores. Below-threshold ratings can trigger same-session improvement prompts up to `maxImprovementAttempts`, but every improved plan is parsed through the existing planning JSON extractor and `PlanningPayloadValidator`, so DAG order, task keys, dependency references, and required prompt sections remain mandatory. +- Planning reflection is optional and fail-open. Malformed reflection JSON, provider failures, or invalid improved planning JSON are logged and leave the last valid parsed plan in place rather than bypassing validation or corrupting the accepted output. +- Reflection audit records are appended to the planning execution invocation as message metadata. The metadata stores criteria, thresholds, scores, pass/fail state, attempt count, and final decision; it does not duplicate provider credentials. ### 4. Orchestration cycle For `status` and `orchestrate`, each cycle follows the strict execution order defined in `CycleRunner.run`: @@ -173,6 +176,7 @@ For `action=status`: - In `WHEN_GREEN` feature PR mode, a clean PR with no check-rollup entries and no tracked CI runs is treated as CI-skipped after a 10 minute grace window. This prevents feature PRs from heartbeating forever when the repository has PR workflows but no run is ever materialized for that branch. - When QA requests fixes and Code UX applies them through a same-session CLI follow-up, the next sprint cycle treats the completed `cli_task_followup` invocation as fresh task work and reruns QA verification instead of waiting for a separate task-run completion timestamp. If a restart happens before that invocation marker is persisted, the cycle can also use a later completed task run for the task's current session as recovery evidence, preventing a completed fix attempt from staying parked at `CODING_COMPLETED`/`QA_PENDING` forever. - Task-completion and completed-without-PR QA use the trigger's ordered `agentPresetIds` list. `[]` means zero custom reviewer IDs plus one built-in/default QA fallback; one ID runs one reviewer; multiple IDs run multiple reviewers in order. Each resolved reviewer creates its own `qa_review_runs` row in the same review cycle with the same `run_index`, for example `agent-qa-security` and `agent-qa-regression` can both review `project-123` task `T02` in run `2`. The cycle passes only when every reviewer passes. Any reviewer that requests changes, fails, or is still running keeps the task blocked under the existing QA gate rules, and reviewer rows remain visible per agent. +- QA self-reflection is available under `agents.selfReflection.qualityAssurance` and also defaults to disabled. When enabled, QA results follow the same rate-and-improve loop as planning, and any improved QA output must pass the normal normalized QA schema before it can replace the previous valid result. - When CLI QA follow-up work creates or reuses a task PR after an earlier PR for the same task was already merged, Code UX clears stale merged state and persists the task as code-complete again so the feature PR gate can evaluate and auto-merge the follow-up PR. - When a task is parked in `QA_REVIEW_FAILED` but its feature PR is later merged manually, the feature PR gate treats the merged PR as authoritative, marks the task `COMPLETED`/`MERGED`, and lets dependent tasks proceed. - Repeated watch-loop cycles must be idempotent. Retryable CI observations such as pending checks, pending PR mergeability, and armed auto-merge keep the task in `RUNNING` with the `CI` merge indicator, emit stable task-run event keys, and must not consume another CI-fix retry or redispatch dependent work. diff --git a/src/services/structured-agent-request-service.ts b/src/services/structured-agent-request-service.ts index 169203c899..eeabe4cef7 100644 --- a/src/services/structured-agent-request-service.ts +++ b/src/services/structured-agent-request-service.ts @@ -1,9 +1,10 @@ import { randomUUID } from "crypto"; -import type { DashboardSettings, ProviderId, QwenModelProviderSettings, VirtualWorkerProvider } from "../contracts/app-types.js"; +import type { AgentSelfReflectionLoopSettings, DashboardSettings, ProviderId, QwenModelProviderSettings, VirtualWorkerProvider } from "../contracts/app-types.js"; import type { ProviderInvocationPurpose } from "../contracts/execution-types.js"; import type { Logger } from "../shared/logging/logger.js"; import type { ExecutionRepository } from "../repositories/execution-repository.js"; -import { StructuredProviderResponseService, type StructuredProviderResult } from "./structured-provider-response-service.js"; +import { StructuredProviderResponseService, type StructuredExecutionArgs, type StructuredProviderResult } from "./structured-provider-response-service.js"; +import { extractJsonFromText } from "../domain/llm/json-extraction.js"; export interface StructuredRequestArgs { projectId: string; @@ -59,6 +60,31 @@ export interface StructuredAgentRequestResult extends StructuredProviderResul invocationId: string; } +interface ReflectionCriterionResult { + id: string; + label: string; + score: number; + rationale: string; + improvementInstructions: string; + threshold: number; + passed: boolean; +} + +interface ReflectionEvaluation { + criteria: ReflectionCriterionResult[]; + passed: boolean; +} + +interface ReflectionRunState { + parsed: unknown; + bodyMarkdown: string; + nativeSessionId: string | null; + continueSessionId: string | null; + openCodeBaselineRawUsageJson: Record | null; + attemptCount: number; + finalDecision: "passed" | "max_attempts_reached" | "reflection_failed" | "improvement_failed"; +} + export interface StructuredAgentRequestServiceDeps { executionRepository?: ExecutionRepository; structuredProviderResponseService: StructuredProviderResponseService; @@ -166,13 +192,415 @@ export class StructuredAgentRequestService { buildRetryPrompt: args.buildRetryPrompt, }); + const reflected = await this.runSelfReflectionIfEnabled(args, { + parsed: result.parsed, + bodyMarkdown: result.bodyMarkdown, + nativeSessionId: result.nativeSessionId, + continueSessionId: result.nativeSessionId || sessionId, + openCodeBaselineRawUsageJson: result.openCodeBaselineRawUsageJson || args.openCodeBaselineRawUsageJson || null, + attemptCount: 0, + finalDecision: "passed", + }, sessionId, invocationId, maxRetries); + return { - ...result, + parsed: reflected.parsed as T, + bodyMarkdown: reflected.bodyMarkdown, + nativeSessionId: reflected.nativeSessionId, sessionId, invocationId: invocationId || "", }; } + private async runSelfReflectionIfEnabled( + args: StructuredRequestArgs, + initial: ReflectionRunState, + sessionId: string, + invocationId: string | undefined, + maxRetries: number, + ): Promise { + const settings = this.resolveReflectionSettings(args); + if (!settings || !settings.enabled || settings.criteria.length === 0) { + return initial; + } + + let state = initial; + let evaluation: ReflectionEvaluation | null = null; + const maxImprovementAttempts = Math.max(0, Math.floor(settings.maxImprovementAttempts)); + + for (let attempt = 0; attempt <= maxImprovementAttempts; attempt += 1) { + try { + evaluation = await this.evaluateReflection(args, settings, state, sessionId, invocationId, attempt, maxRetries); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.deps.logger?.warn(`${args.purpose} self-reflection failed; keeping last valid output`, { + projectId: args.projectId, + sprintId: args.sprintId || null, + taskId: args.taskId || null, + attempt, + error: message, + }); + this.persistReflectionMetadata(invocationId, { + event: "reflection_failed", + purpose: args.purpose, + attempt, + criteria: settings.criteria, + passed: false, + finalDecision: "reflection_failed", + errorMessage: message, + }); + return { + ...state, + attemptCount: attempt, + finalDecision: "reflection_failed", + }; + } + + this.persistReflectionMetadata(invocationId, { + event: "reflection_evaluated", + purpose: args.purpose, + attempt, + criteria: settings.criteria, + scores: evaluation.criteria, + passed: evaluation.passed, + finalDecision: evaluation.passed ? "passed" : attempt >= maxImprovementAttempts ? "max_attempts_reached" : "improvement_requested", + }); + + if (evaluation.passed) { + return { + ...state, + attemptCount: attempt, + finalDecision: "passed", + }; + } + + if (attempt >= maxImprovementAttempts) { + return { + ...state, + attemptCount: attempt, + finalDecision: "max_attempts_reached", + }; + } + + try { + const improved = await this.requestReflectionImprovement(args, evaluation, state, sessionId, invocationId, maxRetries); + state = { + parsed: improved.parsed, + bodyMarkdown: improved.bodyMarkdown, + nativeSessionId: improved.nativeSessionId, + continueSessionId: improved.nativeSessionId || state.continueSessionId || sessionId, + openCodeBaselineRawUsageJson: improved.openCodeBaselineRawUsageJson || state.openCodeBaselineRawUsageJson, + attemptCount: attempt + 1, + finalDecision: "passed", + }; + this.persistReflectionMetadata(invocationId, { + event: "reflection_improved", + purpose: args.purpose, + attempt: attempt + 1, + criteria: settings.criteria, + passed: false, + finalDecision: "improvement_parsed", + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.deps.logger?.warn(`${args.purpose} self-reflection improvement failed; keeping last valid output`, { + projectId: args.projectId, + sprintId: args.sprintId || null, + taskId: args.taskId || null, + attempt: attempt + 1, + error: message, + }); + this.persistReflectionMetadata(invocationId, { + event: "reflection_improvement_failed", + purpose: args.purpose, + attempt: attempt + 1, + criteria: settings.criteria, + scores: evaluation.criteria, + passed: false, + finalDecision: "improvement_failed", + errorMessage: message, + }); + return { + ...state, + attemptCount: attempt + 1, + finalDecision: "improvement_failed", + }; + } + } + + return state; + } + + private resolveReflectionSettings(args: StructuredRequestArgs): AgentSelfReflectionLoopSettings | null { + if (args.purpose === "planning") { + return args.settings.agents?.selfReflection?.planning || null; + } + if (args.purpose === "qa_review") { + return args.settings.agents?.selfReflection?.qualityAssurance || null; + } + return null; + } + + private async evaluateReflection( + args: StructuredRequestArgs, + settings: AgentSelfReflectionLoopSettings, + state: ReflectionRunState, + sessionId: string, + invocationId: string | undefined, + attempt: number, + maxRetries: number, + ): Promise { + const prompt = this.buildReflectionEvaluationPrompt(args.purpose, args.providerPrompt, state.parsed, settings, attempt); + const result = await this.deps.structuredProviderResponseService.executeAndParse({ + ...this.buildReflectionExecutionArgs(args, sessionId, invocationId, state.continueSessionId, state.openCodeBaselineRawUsageJson, maxRetries), + prompt, + parseFn: (text) => this.parseReflectionEvaluation(text, settings), + buildRetryPrompt: (error) => [ + "Your previous self-reflection response was not valid JSON.", + `Validation error: ${error.message}`, + "", + "Return JSON only with the requested criteria ratings.", + ].join("\n"), + retryProviderFailures: false, + maxProviderAttempts: undefined, + }); + state.continueSessionId = result.nativeSessionId || state.continueSessionId || sessionId; + state.openCodeBaselineRawUsageJson = result.openCodeBaselineRawUsageJson || state.openCodeBaselineRawUsageJson; + return result.parsed; + } + + private async requestReflectionImprovement( + args: StructuredRequestArgs, + evaluation: ReflectionEvaluation, + state: ReflectionRunState, + sessionId: string, + invocationId: string | undefined, + maxRetries: number, + ): Promise> { + const prompt = this.buildReflectionImprovementPrompt(args.purpose, args.providerPrompt, state.parsed, evaluation); + return await this.deps.structuredProviderResponseService.executeAndParse({ + ...this.buildReflectionExecutionArgs(args, sessionId, invocationId, state.continueSessionId, state.openCodeBaselineRawUsageJson, maxRetries), + prompt, + parseFn: args.parseFn, + buildRetryPrompt: args.buildRetryPrompt, + retryProviderFailures: false, + maxProviderAttempts: undefined, + }); + } + + private buildReflectionExecutionArgs( + args: StructuredRequestArgs, + sessionId: string, + invocationId: string | undefined, + continueSessionId: string | null, + openCodeBaselineRawUsageJson: Record | null, + maxRetries: number, + ): StructuredExecutionArgs { + return { + projectId: args.projectId, + sprintId: args.sprintId || null, + taskId: args.taskId || null, + sprintRunId: args.sprintRunId || null, + taskRunId: args.taskRunId || null, + purpose: args.purpose, + type: args.type, + provider: args.provider as VirtualWorkerProvider, + maxConcurrentTasks: args.maxConcurrentTasks, + prompt: "", + cwd: args.cwd, + model: args.model, + apiKey: args.apiKey, + qwenAuthMode: args.qwenAuthMode, + qwenRegion: args.qwenRegion, + qwenBaseUrl: args.qwenBaseUrl, + qwenEnvKey: args.qwenEnvKey, + qwenModelId: args.qwenModelId, + qwenProtocol: args.qwenProtocol, + qwenAdditionalModelProviders: args.qwenAdditionalModelProviders, + openCodeAuthMode: args.openCodeAuthMode, + openCodeProviderId: args.openCodeProviderId, + openCodeModelId: args.openCodeModelId, + openCodeBaseUrl: args.openCodeBaseUrl, + openCodeEnvKey: args.openCodeEnvKey, + openCodePackage: args.openCodePackage, + providerMountAuth: args.providerMountAuth, + providerAuthPath: args.providerAuthPath, + customBaseUrl: args.customBaseUrl, + customModel: args.customModel, + sessionId, + workspaceSessionId: args.workspaceSessionId, + workflowSettings: args.settings.cliWorkflow, + repoPath: args.repoPath, + githubToken: args.githubToken, + signal: args.signal, + invocationId, + continueSessionId, + openCodeBaselineRawUsageJson: args.provider === "opencode" ? openCodeBaselineRawUsageJson : undefined, + onActivity: args.onActivity, + settings: args.settings, + maxRetries, + providerLabel: args.providerLabel, + parseFn: (text: string) => text, + buildRetryPrompt: (error: Error) => error.message, + }; + } + + private buildReflectionEvaluationPrompt( + purpose: ProviderInvocationPurpose, + originalPrompt: string, + parsedOutput: unknown, + settings: AgentSelfReflectionLoopSettings, + attempt: number, + ): string { + return [ + "You are evaluating your own structured output for Code UX.", + `Invocation purpose: ${purpose}.`, + `Reflection attempt: ${attempt}.`, + "", + "Rate the parsed output against each criterion from 1 to 10. Use the threshold as the minimum passing score after converting it to a 10-point scale.", + "", + "## Original Prompt", + originalPrompt, + "", + "## Parsed Output", + JSON.stringify(parsedOutput, null, 2), + "", + "## Criteria", + ...settings.criteria.map((criterion) => `- ${criterion.id} (${criterion.label}): ${criterion.prompt} Threshold: ${(criterion.threshold * 10).toFixed(1)}/10.`), + "", + "## Required Output", + "Return JSON only with this exact shape:", + "{\"criteria\":[{\"id\":\"criterion_id\",\"score\":8,\"rationale\":\"Brief reason\",\"improvementInstructions\":\"Specific instruction if below threshold, otherwise empty string\"}]}", + ].join("\n"); + } + + private buildReflectionImprovementPrompt( + purpose: ProviderInvocationPurpose, + originalPrompt: string, + parsedOutput: unknown, + evaluation: ReflectionEvaluation, + ): string { + const failed = evaluation.criteria.filter((criterion) => !criterion.passed); + return [ + "Improve your previous structured JSON output for Code UX.", + `Invocation purpose: ${purpose}.`, + "", + "Keep the original output contract exactly. Return only the improved JSON payload for the original request, with no markdown fences or commentary.", + "", + "## Original Prompt", + originalPrompt, + "", + "## Previous Parsed Output", + JSON.stringify(parsedOutput, null, 2), + "", + "## Required Improvements", + ...failed.map((criterion) => [ + `- ${criterion.id} (${criterion.label}) scored ${criterion.score}/10; threshold ${(criterion.threshold * 10).toFixed(1)}/10.`, + ` Rationale: ${criterion.rationale}`, + ` Improvement: ${criterion.improvementInstructions || "Raise this criterion while preserving the requested JSON schema."}`, + ].join("\n")), + ].join("\n"); + } + + private parseReflectionEvaluation(text: string, settings: AgentSelfReflectionLoopSettings): ReflectionEvaluation { + const extraction = extractJsonFromText(text); + if (!extraction.success) { + throw new Error("Self-reflection reply was not valid JSON."); + } + const payload = extraction.data; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("Self-reflection payload must be a JSON object."); + } + const criteriaPayload = (payload as Record).criteria; + if (!Array.isArray(criteriaPayload)) { + throw new Error("Self-reflection payload must include a criteria array."); + } + + const byId = new Map(settings.criteria.map((criterion) => [criterion.id, criterion])); + const results: ReflectionCriterionResult[] = []; + for (const entry of criteriaPayload) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + continue; + } + const input = entry as Record; + const id = typeof input.id === "string" ? input.id.trim() : ""; + const setting = byId.get(id); + if (!setting) { + continue; + } + const score = typeof input.score === "number" && Number.isFinite(input.score) + ? Math.max(1, Math.min(10, input.score)) + : Number.NaN; + if (!Number.isFinite(score)) { + throw new Error(`Self-reflection criterion "${id}" is missing a numeric score.`); + } + results.push({ + id, + label: setting.label, + score, + rationale: typeof input.rationale === "string" ? input.rationale.trim() : "", + improvementInstructions: typeof input.improvementInstructions === "string" ? input.improvementInstructions.trim() : "", + threshold: setting.threshold, + passed: score / 10 >= setting.threshold, + }); + } + + const missing = settings.criteria.filter((criterion) => !results.some((result) => result.id === criterion.id)); + if (missing.length > 0) { + throw new Error(`Self-reflection payload is missing criteria: ${missing.map((criterion) => criterion.id).join(", ")}`); + } + + return { + criteria: results, + passed: results.every((result) => result.passed), + }; + } + + private persistReflectionMetadata( + invocationId: string | undefined, + metadata: { + event: string; + purpose: ProviderInvocationPurpose; + attempt: number; + criteria: AgentSelfReflectionLoopSettings["criteria"]; + scores?: ReflectionCriterionResult[]; + passed: boolean; + finalDecision: string; + errorMessage?: string; + }, + ): void { + if (!invocationId) { + return; + } + this.deps.executionRepository?.appendExecutionInvocationMessage(invocationId, { + role: "system", + contentMarkdown: `Self-reflection ${metadata.event} for ${metadata.purpose}: ${metadata.finalDecision}.`, + metadata: { + reflection: { + event: metadata.event, + purpose: metadata.purpose, + attempt: metadata.attempt, + criteria: metadata.criteria.map((criterion) => ({ + id: criterion.id, + label: criterion.label, + threshold: criterion.threshold, + })), + scores: metadata.scores?.map((score) => ({ + id: score.id, + label: score.label, + score: score.score, + threshold: score.threshold, + passed: score.passed, + rationale: score.rationale, + improvementInstructions: score.improvementInstructions, + })) || [], + passed: metadata.passed, + finalDecision: metadata.finalDecision, + errorMessage: metadata.errorMessage, + }, + }, + }); + } + private resolveStructuredRetryCount(args: StructuredRequestArgs): number { if (args.maxRetries !== undefined) { return args.maxRetries; diff --git a/src/services/structured-provider-response-service.ts b/src/services/structured-provider-response-service.ts index 33e11a9e84..6d93969a0b 100644 --- a/src/services/structured-provider-response-service.ts +++ b/src/services/structured-provider-response-service.ts @@ -18,6 +18,7 @@ export interface StructuredProviderResult { parsed: T; nativeSessionId: string | null; bodyMarkdown: string; + openCodeBaselineRawUsageJson?: Record | null; } export class ProviderTransportError extends Error { @@ -153,6 +154,7 @@ export class StructuredProviderResponseService { parsed, nativeSessionId, bodyMarkdown, + openCodeBaselineRawUsageJson, }; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); diff --git a/tests/backend/domain/qa-review/qa-review-runner.test.ts b/tests/backend/domain/qa-review/qa-review-runner.test.ts index e0afe0b0cb..a0c2e20f18 100644 --- a/tests/backend/domain/qa-review/qa-review-runner.test.ts +++ b/tests/backend/domain/qa-review/qa-review-runner.test.ts @@ -47,6 +47,40 @@ describe("QaReviewRunner", () => { }); }); + it("routes QA review requests with the provided self-reflection settings", async () => { + const settings = { + agents: { + selfReflection: { + qualityAssurance: { + enabled: true, + criteria: [ + { + id: "correctness", + label: "Correctness", + prompt: "The review is correct.", + threshold: 0.8, + }, + ], + maxImprovementAttempts: 1, + }, + }, + }, + }; + structuredAgentRequestService.executeRequest.mockResolvedValueOnce({ + parsed: { verdict: "pass", summary: "Looks good" }, + } as any); + + await runner.runQaReview({ + ...defaultArgs, + settings, + }); + + expect(structuredAgentRequestService.executeRequest).toHaveBeenCalledWith(expect.objectContaining({ + purpose: "qa_review", + settings, + })); + }); + it("should return explicit errored outcome with parse_failure when parse fails", async () => { structuredAgentRequestService.executeRequest.mockRejectedValueOnce(new Error("Invalid JSON format: Failed to extract valid JSON from text.")); diff --git a/tests/backend/services/planning-json-extractor.test.ts b/tests/backend/services/planning-json-extractor.test.ts index bb86036398..3ec6cf9bb1 100644 --- a/tests/backend/services/planning-json-extractor.test.ts +++ b/tests/backend/services/planning-json-extractor.test.ts @@ -1,5 +1,22 @@ import { describe, it, expect } from "vitest"; -import { extractJsonLikeBlock } from "../../../src/services/planning-json-extractor.js"; +import { extractJsonLikeBlock, parsePlannedSprintReply } from "../../../src/services/planning-json-extractor.js"; + +const validPromptMarkdown = [ + "## Objective", + "Do the work.", + "", + "## Scope", + "- src/example.ts", + "", + "## Implementation Requirements", + "1. Implement the change.", + "", + "## Constraints", + "- Keep scope tight.", + "", + "## Verification", + "- Run the focused test.", +].join("\n"); describe("extractJsonLikeBlock", () => { it("extracts plain top-level JSON", () => { @@ -132,4 +149,42 @@ ${directPayload} // Parsing should be fast expect(end - start).toBeLessThan(100); }); + + it("validates strict DAG order after extracting planning JSON", () => { + const payload = JSON.stringify({ + goal: "test", + tasks: [ + { + key: "T01", + title: "First task", + description: "References a later task.", + promptMarkdown: validPromptMarkdown, + priority: "medium", + executorType: "auto", + dependsOn: ["T02"], + }, + ], + }); + + expect(() => parsePlannedSprintReply(payload)).toThrow(/defined later/); + }); + + it("validates required prompt sections after extracting planning JSON", () => { + const payload = JSON.stringify({ + goal: "test", + tasks: [ + { + key: "T01", + title: "First task", + description: "Uses an incomplete prompt.", + promptMarkdown: "## Objective\nDo the work.", + priority: "medium", + executorType: "auto", + dependsOn: [], + }, + ], + }); + + expect(() => parsePlannedSprintReply(payload)).toThrow(/missing required section/); + }); }); diff --git a/tests/backend/services/structured-agent-request-service.test.ts b/tests/backend/services/structured-agent-request-service.test.ts index 267374816b..c85f65062d 100644 --- a/tests/backend/services/structured-agent-request-service.test.ts +++ b/tests/backend/services/structured-agent-request-service.test.ts @@ -2,6 +2,98 @@ import { describe, expect, it, vi } from "vitest"; import { StructuredAgentRequestService } from "../../../src/services/structured-agent-request-service.js"; import { StructuredProviderResponseService } from "../../../src/services/structured-provider-response-service.js"; import type { ProviderExecutionService } from "../../../src/services/provider-execution-service.js"; +import { normalizeQaReviewResult } from "../../../src/domain/qa-review/qa-review-result-normalizer.js"; +import { parsePlannedSprintReply } from "../../../src/services/planning-json-extractor.js"; + +const reflectionSettings = (maxImprovementAttempts = 1) => ({ + cliWorkflow: { + maxParsingRetries: 0, + maxPlanningJsonRetries: 0, + }, + agents: { + selfReflection: { + planning: { + enabled: true, + criteria: [ + { + id: "correctness", + label: "Correctness", + prompt: "The output is correct.", + threshold: 0.8, + }, + ], + maxImprovementAttempts, + }, + qualityAssurance: { + enabled: true, + criteria: [ + { + id: "correctness", + label: "Correctness", + prompt: "The review is correct.", + threshold: 0.8, + }, + ], + maxImprovementAttempts, + }, + }, + }, +}); + +const reflectionPass = (score = 9) => JSON.stringify({ + criteria: [ + { + id: "correctness", + score, + rationale: "Meets the requested criteria.", + improvementInstructions: "", + }, + ], +}); + +const reflectionFail = JSON.stringify({ + criteria: [ + { + id: "correctness", + score: 5, + rationale: "The output is incomplete.", + improvementInstructions: "Add the missing required detail.", + }, + ], +}); + +const validPromptMarkdown = [ + "## Objective", + "Do the work.", + "", + "## Scope", + "- src/example.ts", + "", + "## Implementation Requirements", + "1. Implement the change.", + "", + "## Constraints", + "- Keep scope tight.", + "", + "## Verification", + "- Run the focused test.", +].join("\n"); + +const planningPayload = (overrides: Record = {}) => JSON.stringify({ + goal: "Plan the sprint.", + tasks: [ + { + key: "T01", + title: "Implement first task", + description: "Implement the first task.", + promptMarkdown: validPromptMarkdown, + priority: "medium", + executorType: "auto", + dependsOn: [], + ...overrides, + }, + ], +}); describe("StructuredAgentRequestService", () => { it("parses valid JSON output successfully without retrying", async () => { @@ -519,4 +611,243 @@ describe("StructuredAgentRequestService", () => { contentMarkdown: "System route message", })); }); + + it("keeps self-reflection disabled by default", async () => { + const mockProviderExecutionService = { + executeProvider: vi.fn().mockResolvedValue({ + ok: true, + text: '{"result": "ok"}', + nativeSessionId: "native-1", + }), + } as unknown as ProviderExecutionService; + + const service = new StructuredAgentRequestService({ + structuredProviderResponseService: new StructuredProviderResponseService({ + providerExecutionService: mockProviderExecutionService, + }), + }); + + const result = await service.executeRequest<{ result: string }>({ + projectId: "proj-1", + purpose: "planning", + type: "planning", + provider: "claude-code", + model: "model-1", + apiKey: "test-key", + providerPrompt: "initial prompt", + repoPath: "/repo", + settings: {} as any, + parseFn: (text) => JSON.parse(text), + buildRetryPrompt: () => "retry", + providerLabel: "Claude", + sessionIdPrefix: "test", + }); + + expect(result.parsed).toEqual({ result: "ok" }); + expect(mockProviderExecutionService.executeProvider).toHaveBeenCalledTimes(1); + }); + + it("improves a below-threshold planning output in the same provider session", async () => { + const mockExecutionRepository = { + createExecutionInvocation: vi.fn().mockReturnValue({ id: "inv-reflect" }), + appendExecutionInvocationMessage: vi.fn(), + listExecutionInvocationMessages: vi.fn().mockReturnValue([]), + }; + const mockProviderExecutionService = { + executeProvider: vi.fn() + .mockResolvedValueOnce({ ok: true, text: '{"result": "rough"}', nativeSessionId: "native-1" }) + .mockResolvedValueOnce({ ok: true, text: reflectionFail, nativeSessionId: "native-1" }) + .mockResolvedValueOnce({ ok: true, text: '{"result": "better"}', nativeSessionId: "native-1" }) + .mockResolvedValueOnce({ ok: true, text: reflectionPass(), nativeSessionId: "native-1" }), + } as unknown as ProviderExecutionService; + + const service = new StructuredAgentRequestService({ + executionRepository: mockExecutionRepository as any, + structuredProviderResponseService: new StructuredProviderResponseService({ + providerExecutionService: mockProviderExecutionService, + }), + }); + + const result = await service.executeRequest<{ result: string }>({ + projectId: "proj-1", + purpose: "planning", + type: "planning", + provider: "claude-code", + model: "model-1", + apiKey: "test-key", + providerPrompt: "initial prompt", + repoPath: "/repo", + settings: reflectionSettings() as any, + parseFn: (text) => JSON.parse(text), + buildRetryPrompt: () => "retry", + providerLabel: "Claude", + sessionIdPrefix: "test", + }); + + expect(result.parsed).toEqual({ result: "better" }); + expect(mockProviderExecutionService.executeProvider).toHaveBeenCalledTimes(4); + const calls = vi.mocked(mockProviderExecutionService.executeProvider).mock.calls; + expect(calls[1]?.[0].continueSessionId).toBe("native-1"); + expect(calls[2]?.[0].prompt).toContain("Improve your previous structured JSON output"); + expect(mockExecutionRepository.appendExecutionInvocationMessage).toHaveBeenCalledWith("inv-reflect", expect.objectContaining({ + metadata: expect.objectContaining({ + reflection: expect.objectContaining({ event: "reflection_improved" }), + }), + })); + }); + + it("stops at the max reflection attempt limit and keeps the last valid output", async () => { + const mockProviderExecutionService = { + executeProvider: vi.fn() + .mockResolvedValueOnce({ ok: true, text: '{"result": "rough"}', nativeSessionId: "native-1" }) + .mockResolvedValueOnce({ ok: true, text: reflectionFail, nativeSessionId: "native-1" }), + } as unknown as ProviderExecutionService; + + const service = new StructuredAgentRequestService({ + structuredProviderResponseService: new StructuredProviderResponseService({ + providerExecutionService: mockProviderExecutionService, + }), + }); + + const result = await service.executeRequest<{ result: string }>({ + projectId: "proj-1", + purpose: "planning", + type: "planning", + provider: "claude-code", + model: "model-1", + apiKey: "test-key", + providerPrompt: "initial prompt", + repoPath: "/repo", + settings: reflectionSettings(0) as any, + parseFn: (text) => JSON.parse(text), + buildRetryPrompt: () => "retry", + providerLabel: "Claude", + sessionIdPrefix: "test", + }); + + expect(result.parsed).toEqual({ result: "rough" }); + expect(mockProviderExecutionService.executeProvider).toHaveBeenCalledTimes(2); + }); + + it("falls back to the accepted output when reflection JSON is malformed", async () => { + const mockProviderExecutionService = { + executeProvider: vi.fn() + .mockResolvedValueOnce({ ok: true, text: '{"result": "accepted"}', nativeSessionId: "native-1" }) + .mockResolvedValueOnce({ ok: true, text: "not json", nativeSessionId: "native-1" }), + } as unknown as ProviderExecutionService; + + const service = new StructuredAgentRequestService({ + structuredProviderResponseService: new StructuredProviderResponseService({ + providerExecutionService: mockProviderExecutionService, + }), + }); + + const result = await service.executeRequest<{ result: string }>({ + projectId: "proj-1", + purpose: "planning", + type: "planning", + provider: "claude-code", + model: "model-1", + apiKey: "test-key", + providerPrompt: "initial prompt", + repoPath: "/repo", + settings: reflectionSettings() as any, + maxRetries: 0, + parseFn: (text) => JSON.parse(text), + buildRetryPrompt: () => "retry", + providerLabel: "Claude", + sessionIdPrefix: "test", + }); + + expect(result.parsed).toEqual({ result: "accepted" }); + expect(mockProviderExecutionService.executeProvider).toHaveBeenCalledTimes(2); + }); + + it("revalidates improved planning JSON and keeps the original when the DAG is invalid", async () => { + const original = planningPayload(); + const invalidImprovement = JSON.stringify({ + goal: "Plan the sprint.", + tasks: [ + { + key: "T01", + title: "Invalid task", + description: "Invalid forward dependency.", + promptMarkdown: validPromptMarkdown, + priority: "medium", + executorType: "auto", + dependsOn: ["T02"], + }, + ], + }); + const mockProviderExecutionService = { + executeProvider: vi.fn() + .mockResolvedValueOnce({ ok: true, text: original, nativeSessionId: "native-1" }) + .mockResolvedValueOnce({ ok: true, text: reflectionFail, nativeSessionId: "native-1" }) + .mockResolvedValueOnce({ ok: true, text: invalidImprovement, nativeSessionId: "native-1" }), + } as unknown as ProviderExecutionService; + + const service = new StructuredAgentRequestService({ + structuredProviderResponseService: new StructuredProviderResponseService({ + providerExecutionService: mockProviderExecutionService, + }), + }); + + const result = await service.executeRequest({ + projectId: "proj-1", + purpose: "planning", + type: "planning", + provider: "claude-code", + model: "model-1", + apiKey: "test-key", + providerPrompt: "initial prompt", + repoPath: "/repo", + settings: reflectionSettings() as any, + maxRetries: 0, + parseFn: (text) => parsePlannedSprintReply(text), + buildRetryPrompt: () => "retry", + providerLabel: "Claude", + sessionIdPrefix: "test", + }); + + expect(result.parsed.tasks[0]?.title).toBe("Implement first task"); + expect(mockProviderExecutionService.executeProvider).toHaveBeenCalledTimes(3); + }); + + it("revalidates improved QA JSON and keeps the original when schema normalization fails", async () => { + const original = JSON.stringify({ verdict: "pass", summary: "Looks good.", findings: [] }); + const invalidImprovement = JSON.stringify({ verdict: "maybe", summary: "Invalid.", findings: [] }); + const mockProviderExecutionService = { + executeProvider: vi.fn() + .mockResolvedValueOnce({ ok: true, text: original, nativeSessionId: "native-1" }) + .mockResolvedValueOnce({ ok: true, text: reflectionFail, nativeSessionId: "native-1" }) + .mockResolvedValueOnce({ ok: true, text: invalidImprovement, nativeSessionId: "native-1" }), + } as unknown as ProviderExecutionService; + + const service = new StructuredAgentRequestService({ + structuredProviderResponseService: new StructuredProviderResponseService({ + providerExecutionService: mockProviderExecutionService, + }), + }); + + const result = await service.executeRequest({ + projectId: "proj-1", + purpose: "qa_review", + type: "qa_review", + provider: "claude-code", + model: "model-1", + apiKey: "test-key", + providerPrompt: "qa prompt", + repoPath: "/repo", + settings: reflectionSettings() as any, + maxRetries: 0, + parseFn: (text) => normalizeQaReviewResult(text), + buildRetryPrompt: () => "retry", + providerLabel: "QA", + sessionIdPrefix: "qa-review", + }); + + expect(result.parsed.verdict).toBe("pass"); + expect(result.parsed.summary).toBe("Looks good."); + expect(mockProviderExecutionService.executeProvider).toHaveBeenCalledTimes(3); + }); }); From 2059088d8d3de1283ca5de92f40118ee0255a3e9 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 01:20:02 +0000 Subject: [PATCH 3/8] feat(task T02): implement via codex --- docs-web/architecture/data-model.md | 6 +- docs-web/user/dashboard/memory.md | 6 + docs/architecture/agent-preset-foundation.md | 12 +- docs/dashboard/memory.md | 8 + src/contracts/app-types.ts | 2 + src/contracts/skill-types.ts | 114 ++++ src/repositories/db/app-db-migrations.ts | 4 + src/repositories/db/app-db-schema.ts | 2 + src/repositories/skill-repository.ts | 538 ++++++++++++++++++ src/services/skill-markdown-parser.ts | 149 +++++ src/services/skill-service.ts | 206 +++++++ .../repositories/skill-repository.test.ts | 135 +++++ tests/backend/services/skill-service.test.ts | 192 +++++++ 13 files changed, 1369 insertions(+), 5 deletions(-) create mode 100644 src/contracts/skill-types.ts create mode 100644 src/repositories/skill-repository.ts create mode 100644 src/services/skill-markdown-parser.ts create mode 100644 src/services/skill-service.ts create mode 100644 tests/backend/repositories/skill-repository.test.ts create mode 100644 tests/backend/services/skill-service.test.ts diff --git a/docs-web/architecture/data-model.md b/docs-web/architecture/data-model.md index 149ee2f100..a0aae732d5 100644 --- a/docs-web/architecture/data-model.md +++ b/docs-web/architecture/data-model.md @@ -157,11 +157,11 @@ Persistent skills are stored separately from project workspaces, memories, knowl | Table | Purpose | | --- | --- | | `skill_storages` | Named, project-owned storage containers that multiple agents can attach to. | -| `skills` | Individual reusable skill records under a storage container. | -| `skill_embeddings` | Embedding metadata and optional vectors for skill search. | +| `skills` | Individual reusable skill records under a storage container, with markdown body, tags, applies-to paths, version, source identity, and content hash. | +| `skill_embeddings` | Embedding model, dimension, chunk index, content hash, and optional vector blob for skill search. | | `agent_skill_storage_bindings` | Normalized agent-to-storage attachments keyed by `(agent_preset_id, storage_id)`. | -The current slice defines contracts and persistence only. Runtime provider mounts, prompt injection, MCP tools, and dashboard controls are not wired yet. +Skill markdown is imported from YAML-like frontmatter plus a body. Frontmatter maps to metadata; the body remains the authoritative agent instruction. Backend retrieval can search all project storages, one storage, or the storages attached to an agent preset. Runtime provider mounts, prompt injection, MCP tools, and dashboard controls are not wired yet. ## Memory diff --git a/docs-web/user/dashboard/memory.md b/docs-web/user/dashboard/memory.md index 6698c9e2a6..259649d9f3 100644 --- a/docs-web/user/dashboard/memory.md +++ b/docs-web/user/dashboard/memory.md @@ -95,6 +95,12 @@ Actions per model: Switching the active model leaves existing memories embedded with the previous model — search results across mixed dimensions are nonsensical. Click **Re-embed all** to re-vectorize the project's memories with the new model. Progress is shown live; you can leave the page and check back. +## Persistent skills + +Persistent skills are reusable agent instructions, not sprint learnings. They are stored in project-owned skill storages, attached to agent presets, and kept out of the project workspace and `.code-ux/` sprint files. + +Skill markdown has frontmatter for `title`, `description`, `tags`, `appliesTo`, and `version`; the markdown body is the stored instruction content. When embeddings are available, skills are vectorized into `skill_embeddings` with model and dimension metadata. Search only compares vectors from the requested project/storage or agent-attached storages, skips dimension mismatches, caps candidate loading, ranks by cosine similarity, and uses skill id as a deterministic tie-breaker. + ## Stats The footer shows aggregate memory statistics: total counts per scope/category, average strength, and the active model. diff --git a/docs/architecture/agent-preset-foundation.md b/docs/architecture/agent-preset-foundation.md index 3a1846b5e1..ed642010e2 100644 --- a/docs/architecture/agent-preset-foundation.md +++ b/docs/architecture/agent-preset-foundation.md @@ -39,7 +39,11 @@ Persistent agent skill storage is modeled separately from memories, knowledge do - `skill_embeddings` stores embedding metadata and optional embedding blobs for skill search. - `agent_skill_storage_bindings` attaches agent presets to one or more storage containers through a normalized `(agent_preset_id, storage_id)` binding. -The shared preset contract exposes `persistentSkillStorageIds?: string[]` plus optional `persistentSkillStorage` enablement metadata. The repository round-trips those IDs through `agent_skill_storage_bindings`; it does not use a workspace path field for skill attachment state. Runtime mounting, provider prompt injection, MCP tools, and dashboard controls are intentionally not implemented in this foundation slice. +The shared preset contract exposes `persistentSkillStorageIds?: string[]` plus optional `persistentSkillStorage` enablement metadata. The repository round-trips those IDs through `agent_skill_storage_bindings`; it does not use a workspace path field for skill attachment state. Backend persistence, markdown import/export, and vector retrieval are implemented by `SkillRepository`, `SkillMarkdownParser`, and `SkillService`. Runtime mounting, provider prompt injection, MCP tools, and dashboard controls are intentionally not implemented in this slice. + +Skill records are project-bound at every access point. `SkillRepository` validates the owning project for storages, skills, agent attachments, embedding loads, and deletes. Deleting a storage explicitly removes agent bindings, skill embeddings, and skill rows before deleting the container, matching the cascade contract even in tests or adapters where foreign-key behavior is not the only guardrail. + +Skill markdown is stored in the database rather than in project worktrees. Frontmatter fields (`title`, `description`, `tags`, `appliesTo`, `version`) become skill metadata, while the markdown body remains the authoritative instruction content. `skill_embeddings` stores model id, dimension, chunk index, content hash, and vector blob so retrieval can skip stale or dimension-mismatched rows after model changes. The current markdown-sync and Planning agent extensions are documented in: @@ -48,7 +52,11 @@ The current markdown-sync and Planning agent extensions are documented in: Implementation files: - `src/contracts/agent-preset-types.ts` +- `src/contracts/skill-types.ts` - `src/repositories/agent-preset-repository.ts` +- `src/repositories/skill-repository.ts` +- `src/services/skill-markdown-parser.ts` +- `src/services/skill-service.ts` - `src/server/dashboard-server.ts` ## API Surface @@ -78,7 +86,7 @@ Foundation-supported fields: - optional provider instance preference - optional model override - optional per-agent memory injection configuration -- optional persistent skill storage attachments (contract and storage only; no dashboard controls or runtime retrieval yet) +- optional persistent skill storage attachments (backend persistence and retrieval only; no dashboard controls or runtime prompt injection yet) The memory injection configuration is stored in sqlite as `memory_config_json` and parsed back into `AgentMemoryConfig` on reads, matching the existing JSON-column pattern used by `mcp_access_json`. The dashboard editor now initializes that config from the preset, exposes it through a dedicated `Manage Memory` popover, and persists the chosen filters alongside the rest of the preset payload. diff --git a/docs/dashboard/memory.md b/docs/dashboard/memory.md index e6d8f52220..874c488458 100644 --- a/docs/dashboard/memory.md +++ b/docs/dashboard/memory.md @@ -66,6 +66,14 @@ Memory records encapsulate the base `content` string alongside its vectorized by Knowledge document object access is project-scoped. Document read, delete, re-embed, and project-import operations must prove the document belongs to the route or request project before returning content or mutating rows. Legacy unscoped document endpoints require an explicit `projectId` value and treat missing documents and cross-project mismatches as the same not-found response. +## Persistent Skills vs Memory + +Persistent skills are reusable agent instructions, not observations learned during sprint execution. They live in project-owned `skill_storages`, are attached to agent presets through `agent_skill_storage_bindings`, and never write markdown files into the project workspace or `.code-ux/` sprint directories. + +Skill markdown import uses frontmatter fields for `title`, `description`, `tags`, `appliesTo`, and `version`; the body is stored as the authoritative instruction content. Rendering a skill back to markdown reconstructs that metadata from the database and emits the stored body unchanged except for trailing whitespace normalization. + +Skill search uses the same local embedding infrastructure as memory search but reads from `skill_embeddings`. Ordinary skill CRUD does not require an embedding provider: when no model is loaded, skills remain persisted and unembedded. When a provider is available, `SkillService` embeds the rendered skill markdown and stores the model id, vector dimension, chunk index, content hash, and blob. Search loads at most 10,000 candidate vectors from the requested storage set, skips candidates whose stored dimension differs from the query vector, ranks by cosine similarity, and breaks ties by skill id for deterministic top-K results. + ## Long-Term Claims and Evidence Sprint-scoped memories are treated as observations. Durable project knowledge is stored as canonical claims: diff --git a/src/contracts/app-types.ts b/src/contracts/app-types.ts index 29baaaf7c2..c4497a67be 100644 --- a/src/contracts/app-types.ts +++ b/src/contracts/app-types.ts @@ -1057,6 +1057,8 @@ export interface SkillRecord { sourceRef: string | null; contentHash: string; tags: string[]; + appliesTo: string[]; + version: string | null; createdAt: string; updatedAt: string; } diff --git a/src/contracts/skill-types.ts b/src/contracts/skill-types.ts new file mode 100644 index 0000000000..4b2d8ef488 --- /dev/null +++ b/src/contracts/skill-types.ts @@ -0,0 +1,114 @@ +export type SkillStorageKind = "project" | "shared"; +export type SkillSourceType = "manual" | "imported" | "generated"; + +export interface SkillStorageRecord { + id: string; + projectId: string; + name: string; + description: string; + storageKind: SkillStorageKind; + createdAt: string; + updatedAt: string; +} + +export interface SkillRecord { + id: string; + projectId: string; + storageId: string; + name: string; + description: string; + contentMarkdown: string; + sourceType: SkillSourceType; + sourceRef: string | null; + contentHash: string; + tags: string[]; + appliesTo: string[]; + version: string | null; + createdAt: string; + updatedAt: string; +} + +export interface SkillEmbeddingMetadata { + id: string; + projectId: string; + storageId: string; + skillId: string; + embeddingModel: string; + embeddingDimension: number; + chunkIndex: number; + contentHash: string; + createdAt: string; + updatedAt: string; +} + +export interface SkillEmbeddingRecord extends SkillEmbeddingMetadata { + embeddingBlob: Buffer; +} + +export interface AgentSkillStorageAttachment { + agentPresetId: string; + storageId: string; + projectId: string; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreateSkillStorageInput { + id?: string; + name: string; + description?: string; + storageKind?: SkillStorageKind; +} + +export interface UpdateSkillStorageInput { + name?: string; + description?: string; + storageKind?: SkillStorageKind; +} + +export interface CreateSkillInput { + id?: string; + name: string; + description?: string; + contentMarkdown: string; + sourceType?: SkillSourceType; + sourceRef?: string | null; + tags?: string[]; + appliesTo?: string[]; + version?: string | null; +} + +export interface UpdateSkillInput { + name?: string; + description?: string; + contentMarkdown?: string; + sourceType?: SkillSourceType; + sourceRef?: string | null; + tags?: string[]; + appliesTo?: string[]; + version?: string | null; +} + +export interface ParsedSkillMarkdown { + title: string; + description: string; + tags: string[]; + appliesTo: string[]; + version: string | null; + bodyMarkdown: string; +} + +export interface SkillSearchQuery { + projectId: string; + query: string; + storageId?: string; + agentPresetId?: string; + limit?: number; + minSimilarity?: number; +} + +export interface SkillSearchResult { + skill: SkillRecord; + similarity: number; +} diff --git a/src/repositories/db/app-db-migrations.ts b/src/repositories/db/app-db-migrations.ts index d83b50de16..4c8765daa0 100644 --- a/src/repositories/db/app-db-migrations.ts +++ b/src/repositories/db/app-db-migrations.ts @@ -371,6 +371,8 @@ export function runMigrations(db: DatabaseAdapter): void { source_ref TEXT, content_hash TEXT NOT NULL, tags_json TEXT NOT NULL DEFAULT '[]', + applies_to_json TEXT NOT NULL DEFAULT '[]', + version TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, @@ -378,6 +380,8 @@ export function runMigrations(db: DatabaseAdapter): void { UNIQUE (storage_id, name) ) `); + ensureColumn(db, "skills", "applies_to_json", "TEXT NOT NULL DEFAULT '[]'"); + ensureColumn(db, "skills", "version", "TEXT"); db.exec(` CREATE TABLE IF NOT EXISTS skill_embeddings ( id TEXT PRIMARY KEY, diff --git a/src/repositories/db/app-db-schema.ts b/src/repositories/db/app-db-schema.ts index e3e8cb707b..61fcd0800c 100644 --- a/src/repositories/db/app-db-schema.ts +++ b/src/repositories/db/app-db-schema.ts @@ -397,6 +397,8 @@ CREATE TABLE IF NOT EXISTS skills ( source_ref TEXT, content_hash TEXT NOT NULL, tags_json TEXT NOT NULL DEFAULT '[]', + applies_to_json TEXT NOT NULL DEFAULT '[]', + version TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, diff --git a/src/repositories/skill-repository.ts b/src/repositories/skill-repository.ts new file mode 100644 index 0000000000..469a84bc1d --- /dev/null +++ b/src/repositories/skill-repository.ts @@ -0,0 +1,538 @@ +import { createHash, randomUUID } from "crypto"; +import { AppDbStorage } from "./app-db-storage.js"; +import type { DatabaseAdapter } from "./db/database-adapter.js"; +import { EntityNotFoundError, requireRecord, ValidationError } from "./repository-utils.js"; +import { normalizeStringArray } from "../services/skill-markdown-parser.js"; +import type { + AgentSkillStorageAttachment, + CreateSkillInput, + CreateSkillStorageInput, + SkillEmbeddingRecord, + SkillRecord, + SkillSourceType, + SkillStorageKind, + SkillStorageRecord, + UpdateSkillInput, + UpdateSkillStorageInput, +} from "../contracts/skill-types.js"; + +interface SkillStorageRow { + id: string; + project_id: string; + name: string; + description: string | null; + storage_kind: string; + created_at: string; + updated_at: string; +} + +interface SkillRow { + id: string; + project_id: string; + storage_id: string; + name: string; + description: string | null; + content_markdown: string; + source_type: string; + source_ref: string | null; + content_hash: string; + tags_json: string | null; + applies_to_json: string | null; + version: string | null; + created_at: string; + updated_at: string; +} + +interface SkillEmbeddingRow { + id: string; + project_id: string; + storage_id: string; + skill_id: string; + embedding_model: string; + embedding_dimension: number; + chunk_index: number; + content_hash: string; + embedding_blob: Buffer | Uint8Array | null; + created_at: string; + updated_at: string; +} + +interface AgentSkillStorageBindingRow { + agent_preset_id: string; + storage_id: string; + project_id: string; + enabled: number; + created_at: string; + updated_at: string; +} + +const VALID_STORAGE_KINDS = new Set(["project", "shared"]); +const VALID_SOURCE_TYPES = new Set(["manual", "imported", "generated"]); + +export function computeSkillContentHash(contentMarkdown: string): string { + return createHash("sha256").update(contentMarkdown).digest("hex"); +} + +function parseStringArrayJson(value: string | null): string[] { + if (!value) { + return []; + } + try { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) ? normalizeStringArray(parsed.map((entry) => String(entry))) : []; + } catch { + return []; + } +} + +function normalizeStorageKind(value: SkillStorageKind | undefined): SkillStorageKind { + if (!value) { + return "project"; + } + if (!VALID_STORAGE_KINDS.has(value)) { + throw new ValidationError(`Invalid skill storage kind: ${value}`); + } + return value; +} + +function normalizeSourceType(value: SkillSourceType | undefined): SkillSourceType { + if (!value) { + return "manual"; + } + if (!VALID_SOURCE_TYPES.has(value)) { + throw new ValidationError(`Invalid skill source type: ${value}`); + } + return value; +} + +function normalizeName(name: string, entityType: string): string { + const trimmed = name.trim(); + if (!trimmed) { + throw new ValidationError(`${entityType} name is required`); + } + return trimmed; +} + +export class SkillRepository { + private readonly db: DatabaseAdapter; + + constructor(storage: AppDbStorage = new AppDbStorage()) { + this.db = storage.getDatabase(); + } + + createStorage(projectId: string, input: CreateSkillStorageInput): SkillStorageRecord { + this.requireProject(projectId); + const now = new Date().toISOString(); + const id = input.id?.trim() || randomUUID(); + this.db.prepare(` + INSERT INTO skill_storages (id, project_id, name, description, storage_kind, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + id, + projectId, + normalizeName(input.name, "Skill storage"), + input.description?.trim() || "", + normalizeStorageKind(input.storageKind), + now, + now, + ); + return requireRecord(this.getStorage(projectId, id), "Skill storage", id); + } + + listStorages(projectId: string): SkillStorageRecord[] { + this.requireProject(projectId); + const rows = this.db.prepare(` + SELECT * + FROM skill_storages + WHERE project_id = ? + ORDER BY updated_at DESC, created_at DESC, name ASC + `).all(projectId) as unknown as SkillStorageRow[]; + return rows.map((row) => this.mapStorageRow(row)); + } + + getStorage(projectId: string, storageId: string): SkillStorageRecord | null { + const row = this.db.prepare(` + SELECT * + FROM skill_storages + WHERE id = ? + AND project_id = ? + `).get(storageId, projectId) as SkillStorageRow | undefined; + return row ? this.mapStorageRow(row) : null; + } + + updateStorage(projectId: string, storageId: string, input: UpdateSkillStorageInput): SkillStorageRecord { + const current = requireRecord(this.getStorage(projectId, storageId), "Skill storage", storageId); + const now = new Date().toISOString(); + this.db.prepare(` + UPDATE skill_storages + SET name = ?, description = ?, storage_kind = ?, updated_at = ? + WHERE id = ? + AND project_id = ? + `).run( + input.name === undefined ? current.name : normalizeName(input.name, "Skill storage"), + input.description === undefined ? current.description : input.description.trim(), + input.storageKind === undefined ? current.storageKind : normalizeStorageKind(input.storageKind), + now, + storageId, + projectId, + ); + return requireRecord(this.getStorage(projectId, storageId), "Skill storage", storageId); + } + + deleteStorage(projectId: string, storageId: string): void { + requireRecord(this.getStorage(projectId, storageId), "Skill storage", storageId); + this.db.transaction(() => { + this.db.prepare(`DELETE FROM agent_skill_storage_bindings WHERE project_id = ? AND storage_id = ?`).run(projectId, storageId); + this.db.prepare(`DELETE FROM skill_embeddings WHERE project_id = ? AND storage_id = ?`).run(projectId, storageId); + this.db.prepare(`DELETE FROM skills WHERE project_id = ? AND storage_id = ?`).run(projectId, storageId); + this.db.prepare(`DELETE FROM skill_storages WHERE project_id = ? AND id = ?`).run(projectId, storageId); + }); + } + + attachStorageToAgent(projectId: string, agentPresetId: string, storageId: string): AgentSkillStorageAttachment { + this.requireAgent(projectId, agentPresetId); + requireRecord(this.getStorage(projectId, storageId), "Skill storage", storageId); + const now = new Date().toISOString(); + this.db.prepare(` + INSERT INTO agent_skill_storage_bindings ( + agent_preset_id, + storage_id, + project_id, + enabled, + created_at, + updated_at + ) VALUES (?, ?, ?, 1, ?, ?) + ${this.db.dialect.upsert(["agent_preset_id", "storage_id"], ["project_id", "enabled", "updated_at"])} + `).run(agentPresetId, storageId, projectId, now, now); + return requireRecord(this.getAttachment(projectId, agentPresetId, storageId), "Agent skill storage attachment", `${agentPresetId}:${storageId}`); + } + + detachStorageFromAgent(projectId: string, agentPresetId: string, storageId: string): void { + this.requireAgent(projectId, agentPresetId); + requireRecord(this.getStorage(projectId, storageId), "Skill storage", storageId); + this.db.prepare(` + DELETE FROM agent_skill_storage_bindings + WHERE project_id = ? + AND agent_preset_id = ? + AND storage_id = ? + `).run(projectId, agentPresetId, storageId); + } + + listAttachmentsForAgent(projectId: string, agentPresetId: string): AgentSkillStorageAttachment[] { + this.requireAgent(projectId, agentPresetId); + const rows = this.db.prepare(` + SELECT * + FROM agent_skill_storage_bindings + WHERE project_id = ? + AND agent_preset_id = ? + AND enabled = 1 + ORDER BY created_at ASC, storage_id ASC + `).all(projectId, agentPresetId) as unknown as AgentSkillStorageBindingRow[]; + return rows.map((row) => this.mapAttachmentRow(row)); + } + + listStoragesForAgent(projectId: string, agentPresetId: string): SkillStorageRecord[] { + this.requireAgent(projectId, agentPresetId); + const rows = this.db.prepare(` + SELECT s.* + FROM skill_storages s + INNER JOIN agent_skill_storage_bindings b + ON b.storage_id = s.id + AND b.project_id = s.project_id + WHERE b.project_id = ? + AND b.agent_preset_id = ? + AND b.enabled = 1 + ORDER BY b.created_at ASC, s.name ASC + `).all(projectId, agentPresetId) as unknown as SkillStorageRow[]; + return rows.map((row) => this.mapStorageRow(row)); + } + + createSkill(projectId: string, storageId: string, input: CreateSkillInput): SkillRecord { + requireRecord(this.getStorage(projectId, storageId), "Skill storage", storageId); + const now = new Date().toISOString(); + const id = input.id?.trim() || randomUUID(); + const contentMarkdown = input.contentMarkdown.trim(); + this.db.prepare(` + INSERT INTO skills ( + id, + project_id, + storage_id, + name, + description, + content_markdown, + source_type, + source_ref, + content_hash, + tags_json, + applies_to_json, + version, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + projectId, + storageId, + normalizeName(input.name, "Skill"), + input.description?.trim() || "", + contentMarkdown, + normalizeSourceType(input.sourceType), + input.sourceRef?.trim() || null, + computeSkillContentHash(contentMarkdown), + JSON.stringify(normalizeStringArray(input.tags)), + JSON.stringify(normalizeStringArray(input.appliesTo)), + input.version?.trim() || null, + now, + now, + ); + return requireRecord(this.getSkill(projectId, id), "Skill", id); + } + + listSkills(projectId: string, storageId: string, limit = 200): SkillRecord[] { + requireRecord(this.getStorage(projectId, storageId), "Skill storage", storageId); + const rows = this.db.prepare(` + SELECT * + FROM skills + WHERE project_id = ? + AND storage_id = ? + ORDER BY updated_at DESC, created_at DESC, name ASC + LIMIT ? + `).all(projectId, storageId, limit) as unknown as SkillRow[]; + return rows.map((row) => this.mapSkillRow(row)); + } + + getSkill(projectId: string, skillId: string): SkillRecord | null { + const row = this.db.prepare(` + SELECT * + FROM skills + WHERE id = ? + AND project_id = ? + `).get(skillId, projectId) as SkillRow | undefined; + return row ? this.mapSkillRow(row) : null; + } + + getSkills(projectId: string, skillIds: string[]): SkillRecord[] { + if (skillIds.length === 0) { + return []; + } + const uniqueIds = [...new Set(skillIds)]; + const placeholders = uniqueIds.map(() => "?").join(", "); + const rows = this.db.prepare(` + SELECT * + FROM skills + WHERE project_id = ? + AND id IN (${placeholders}) + `).all(projectId, ...uniqueIds) as unknown as SkillRow[]; + const skillMap = new Map(rows.map((row) => [row.id, this.mapSkillRow(row)])); + return skillIds.map((id) => skillMap.get(id)).filter((skill): skill is SkillRecord => Boolean(skill)); + } + + updateSkill(projectId: string, skillId: string, input: UpdateSkillInput): SkillRecord { + const current = requireRecord(this.getSkill(projectId, skillId), "Skill", skillId); + if (input.contentMarkdown !== undefined || input.name !== undefined) { + requireRecord(this.getStorage(projectId, current.storageId), "Skill storage", current.storageId); + } + const now = new Date().toISOString(); + const contentMarkdown = input.contentMarkdown === undefined ? current.contentMarkdown : input.contentMarkdown.trim(); + this.db.prepare(` + UPDATE skills + SET name = ?, + description = ?, + content_markdown = ?, + source_type = ?, + source_ref = ?, + content_hash = ?, + tags_json = ?, + applies_to_json = ?, + version = ?, + updated_at = ? + WHERE id = ? + AND project_id = ? + `).run( + input.name === undefined ? current.name : normalizeName(input.name, "Skill"), + input.description === undefined ? current.description : input.description.trim(), + contentMarkdown, + input.sourceType === undefined ? current.sourceType : normalizeSourceType(input.sourceType), + input.sourceRef === undefined ? current.sourceRef : input.sourceRef?.trim() || null, + computeSkillContentHash(contentMarkdown), + JSON.stringify(input.tags === undefined ? current.tags : normalizeStringArray(input.tags)), + JSON.stringify(input.appliesTo === undefined ? current.appliesTo : normalizeStringArray(input.appliesTo)), + input.version === undefined ? current.version : input.version?.trim() || null, + now, + skillId, + projectId, + ); + return requireRecord(this.getSkill(projectId, skillId), "Skill", skillId); + } + + deleteSkill(projectId: string, skillId: string): void { + requireRecord(this.getSkill(projectId, skillId), "Skill", skillId); + this.db.transaction(() => { + this.db.prepare(`DELETE FROM skill_embeddings WHERE project_id = ? AND skill_id = ?`).run(projectId, skillId); + this.db.prepare(`DELETE FROM skills WHERE project_id = ? AND id = ?`).run(projectId, skillId); + }); + } + + saveEmbedding(projectId: string, skillId: string, embeddingModel: string, embeddingDimension: number, embeddingBlob: Buffer, chunkIndex = 0): void { + const skill = requireRecord(this.getSkill(projectId, skillId), "Skill", skillId); + const now = new Date().toISOString(); + const id = randomUUID(); + this.db.prepare(` + INSERT INTO skill_embeddings ( + id, + project_id, + storage_id, + skill_id, + embedding_model, + embedding_dimension, + chunk_index, + content_hash, + embedding_blob, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ${this.db.dialect.upsert(["skill_id", "embedding_model", "chunk_index"], [ + "project_id", + "storage_id", + "embedding_dimension", + "content_hash", + "embedding_blob", + "updated_at", + ])} + `).run( + id, + projectId, + skill.storageId, + skill.id, + embeddingModel, + embeddingDimension, + chunkIndex, + skill.contentHash, + embeddingBlob, + now, + now, + ); + } + + deleteEmbeddingsForSkill(projectId: string, skillId: string): void { + requireRecord(this.getSkill(projectId, skillId), "Skill", skillId); + this.db.prepare(`DELETE FROM skill_embeddings WHERE project_id = ? AND skill_id = ?`).run(projectId, skillId); + } + + loadEmbeddingsForStorages( + projectId: string, + storageIds: string[], + embeddingModel: string, + limit: number, + ): SkillEmbeddingRecord[] { + const normalizedStorageIds = normalizeStringArray(storageIds); + if (normalizedStorageIds.length === 0) { + return []; + } + for (const storageId of normalizedStorageIds) { + requireRecord(this.getStorage(projectId, storageId), "Skill storage", storageId); + } + const placeholders = normalizedStorageIds.map(() => "?").join(", "); + const rows = this.db.prepare(` + SELECT * + FROM skill_embeddings + WHERE project_id = ? + AND storage_id IN (${placeholders}) + AND embedding_model = ? + AND embedding_blob IS NOT NULL + ORDER BY updated_at DESC, skill_id ASC, chunk_index ASC + LIMIT ? + `).all(projectId, ...normalizedStorageIds, embeddingModel, limit) as unknown as SkillEmbeddingRow[]; + + return rows + .filter((row) => row.embedding_blob !== null) + .map((row) => this.mapEmbeddingRow(row as SkillEmbeddingRow & { embedding_blob: Buffer | Uint8Array })); + } + + private getAttachment(projectId: string, agentPresetId: string, storageId: string): AgentSkillStorageAttachment | null { + const row = this.db.prepare(` + SELECT * + FROM agent_skill_storage_bindings + WHERE project_id = ? + AND agent_preset_id = ? + AND storage_id = ? + `).get(projectId, agentPresetId, storageId) as AgentSkillStorageBindingRow | undefined; + return row ? this.mapAttachmentRow(row) : null; + } + + private requireProject(projectId: string): void { + requireRecord(this.db.prepare(`SELECT id FROM projects WHERE id = ?`).get(projectId), "Project", projectId); + } + + private requireAgent(projectId: string, agentPresetId: string): void { + const row = this.db.prepare(` + SELECT id + FROM agent_presets + WHERE id = ? + AND project_id = ? + `).get(agentPresetId, projectId); + if (!row) { + throw new EntityNotFoundError(`Agent preset not found: ${agentPresetId}`); + } + } + + private mapStorageRow(row: SkillStorageRow): SkillStorageRecord { + return { + id: row.id, + projectId: row.project_id, + name: row.name, + description: row.description || "", + storageKind: VALID_STORAGE_KINDS.has(row.storage_kind as SkillStorageKind) ? row.storage_kind as SkillStorageKind : "project", + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + private mapSkillRow(row: SkillRow): SkillRecord { + return { + id: row.id, + projectId: row.project_id, + storageId: row.storage_id, + name: row.name, + description: row.description || "", + contentMarkdown: row.content_markdown, + sourceType: VALID_SOURCE_TYPES.has(row.source_type as SkillSourceType) ? row.source_type as SkillSourceType : "manual", + sourceRef: row.source_ref, + contentHash: row.content_hash, + tags: parseStringArrayJson(row.tags_json), + appliesTo: parseStringArrayJson(row.applies_to_json), + version: row.version || null, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + private mapEmbeddingRow(row: SkillEmbeddingRow & { embedding_blob: Buffer | Uint8Array }): SkillEmbeddingRecord { + return { + id: row.id, + projectId: row.project_id, + storageId: row.storage_id, + skillId: row.skill_id, + embeddingModel: row.embedding_model, + embeddingDimension: row.embedding_dimension, + chunkIndex: row.chunk_index, + contentHash: row.content_hash, + embeddingBlob: row.embedding_blob instanceof Buffer + ? row.embedding_blob + : Buffer.from(row.embedding_blob.buffer, row.embedding_blob.byteOffset, row.embedding_blob.byteLength), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + private mapAttachmentRow(row: AgentSkillStorageBindingRow): AgentSkillStorageAttachment { + return { + agentPresetId: row.agent_preset_id, + storageId: row.storage_id, + projectId: row.project_id, + enabled: Boolean(row.enabled), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} diff --git a/src/services/skill-markdown-parser.ts b/src/services/skill-markdown-parser.ts new file mode 100644 index 0000000000..175ec64154 --- /dev/null +++ b/src/services/skill-markdown-parser.ts @@ -0,0 +1,149 @@ +import type { ParsedSkillMarkdown, SkillRecord } from "../contracts/skill-types.js"; + +const FRONTMATTER_BOUNDARY = "---"; + +function normalizeString(value: unknown): string { + return String(value ?? "").trim(); +} + +function stripQuotes(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function parseListValue(value: string): string[] { + const trimmed = value.trim(); + if (!trimmed) { + return []; + } + const inner = trimmed.startsWith("[") && trimmed.endsWith("]") + ? trimmed.slice(1, -1) + : trimmed; + return inner + .split(",") + .map((entry) => stripQuotes(entry).trim()) + .filter(Boolean); +} + +function parseFrontmatter(raw: string): { fields: Record; body: string } { + if (!raw.startsWith(`${FRONTMATTER_BOUNDARY}\n`) && !raw.startsWith(`${FRONTMATTER_BOUNDARY}\r\n`)) { + return { fields: {}, body: raw }; + } + + const newline = raw.startsWith(`${FRONTMATTER_BOUNDARY}\r\n`) ? "\r\n" : "\n"; + const closeMatch = raw.slice(FRONTMATTER_BOUNDARY.length + newline.length).match(/\r?\n---(?:\r?\n|$)/); + if (!closeMatch || closeMatch.index === undefined) { + return { fields: {}, body: raw }; + } + + const frontmatterStart = FRONTMATTER_BOUNDARY.length + newline.length; + const closeIndex = frontmatterStart + closeMatch.index; + const frontmatter = raw.slice(frontmatterStart, closeIndex); + const body = raw.slice(closeIndex + closeMatch[0].length); + const fields: Record = {}; + let activeListKey: string | null = null; + + for (const line of frontmatter.split(/\r?\n/)) { + const listMatch = line.match(/^\s*-\s+(.+)$/); + if (activeListKey && listMatch) { + const current = Array.isArray(fields[activeListKey]) ? fields[activeListKey] : []; + fields[activeListKey] = [...current, stripQuotes(listMatch[1]!).trim()].filter(Boolean); + continue; + } + + const pairMatch = line.match(/^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/); + if (!pairMatch) { + activeListKey = null; + continue; + } + + const key = pairMatch[1]!.trim(); + const value = pairMatch[2] ?? ""; + if (value.trim().length === 0) { + fields[key] = []; + activeListKey = key; + continue; + } + activeListKey = null; + fields[key] = key === "tags" || key === "appliesTo" ? parseListValue(value) : stripQuotes(value); + } + + return { fields, body }; +} + +function readStringField(fields: Record, key: string): string { + const value = fields[key]; + return Array.isArray(value) ? "" : normalizeString(value); +} + +function readListField(fields: Record, key: string): string[] { + const value = fields[key]; + if (Array.isArray(value)) { + return normalizeStringArray(value); + } + return parseListValue(value ?? ""); +} + +export function normalizeStringArray(values: readonly string[] | undefined): string[] { + const seen = new Set(); + const normalized: string[] = []; + for (const value of values || []) { + const trimmed = normalizeString(value); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + normalized.push(trimmed); + } + return normalized; +} + +export function parseSkillMarkdown(raw: string): ParsedSkillMarkdown { + const { fields, body } = parseFrontmatter(raw); + return { + title: readStringField(fields, "title"), + description: readStringField(fields, "description"), + tags: readListField(fields, "tags"), + appliesTo: readListField(fields, "appliesTo"), + version: readStringField(fields, "version") || null, + bodyMarkdown: body.replace(/^\s*\n/, "").replace(/\s+$/, ""), + }; +} + +function formatScalar(value: string): string { + if (!/[#:\[\],{}'"`]|^\s|\s$/.test(value)) { + return value; + } + return JSON.stringify(value); +} + +function formatList(values: string[]): string { + return `[${values.map((value) => JSON.stringify(value)).join(", ")}]`; +} + +export function renderSkillMarkdown(skill: Pick): string { + const lines = [ + FRONTMATTER_BOUNDARY, + `title: ${formatScalar(skill.name)}`, + ]; + if (skill.description) { + lines.push(`description: ${formatScalar(skill.description)}`); + } + if (skill.tags.length > 0) { + lines.push(`tags: ${formatList(skill.tags)}`); + } + if (skill.appliesTo.length > 0) { + lines.push(`appliesTo: ${formatList(skill.appliesTo)}`); + } + if (skill.version) { + lines.push(`version: ${formatScalar(skill.version)}`); + } + lines.push(FRONTMATTER_BOUNDARY, "", skill.contentMarkdown.trimEnd(), ""); + return lines.join("\n"); +} diff --git a/src/services/skill-service.ts b/src/services/skill-service.ts new file mode 100644 index 0000000000..42de896e39 --- /dev/null +++ b/src/services/skill-service.ts @@ -0,0 +1,206 @@ +import { SkillRepository } from "../repositories/skill-repository.js"; +import { bufferToFloat32, cosineSimilarity, float32ToBuffer } from "./embedding-vector-utils.js"; +import { parseSkillMarkdown, renderSkillMarkdown } from "./skill-markdown-parser.js"; +import { createLogger, type Logger } from "../shared/logging/logger.js"; +import type { + CreateSkillStorageInput, + SkillRecord, + SkillSearchQuery, + SkillSearchResult, + SkillSourceType, + SkillStorageRecord, + UpdateSkillStorageInput, +} from "../contracts/skill-types.js"; + +export interface SkillEmbeddingProvider { + isLoaded(): boolean; + getLoadedModelId(): string | null; + embed(text: string): Promise; +} + +export interface WriteSkillMarkdownOptions { + skillId?: string; + sourceType?: SkillSourceType; + sourceRef?: string | null; +} + +const MAX_SEARCH_CANDIDATES = 10000; + +export class SkillService { + constructor( + private readonly skillRepository: SkillRepository, + private readonly embeddingService: SkillEmbeddingProvider, + private readonly logger: Logger = createLogger({ bindings: { component: "SkillService" } }), + ) {} + + createStorage(projectId: string, input: CreateSkillStorageInput): SkillStorageRecord { + return this.skillRepository.createStorage(projectId, input); + } + + listStorages(projectId: string): SkillStorageRecord[] { + return this.skillRepository.listStorages(projectId); + } + + updateStorage(projectId: string, storageId: string, input: UpdateSkillStorageInput): SkillStorageRecord { + return this.skillRepository.updateStorage(projectId, storageId, input); + } + + deleteStorage(projectId: string, storageId: string): void { + this.skillRepository.deleteStorage(projectId, storageId); + } + + attachStorageToAgent(projectId: string, agentPresetId: string, storageId: string): void { + this.skillRepository.attachStorageToAgent(projectId, agentPresetId, storageId); + } + + detachStorageFromAgent(projectId: string, agentPresetId: string, storageId: string): void { + this.skillRepository.detachStorageFromAgent(projectId, agentPresetId, storageId); + } + + async writeSkillFromMarkdown( + projectId: string, + storageId: string, + markdown: string, + options: WriteSkillMarkdownOptions = {}, + ): Promise { + const parsed = parseSkillMarkdown(markdown); + const name = parsed.title || "Untitled skill"; + const input = { + name, + description: parsed.description, + contentMarkdown: parsed.bodyMarkdown, + sourceType: options.sourceType ?? "manual", + sourceRef: options.sourceRef ?? null, + tags: parsed.tags, + appliesTo: parsed.appliesTo, + version: parsed.version, + }; + + const skill = options.skillId + ? this.skillRepository.updateSkill(projectId, options.skillId, input) + : this.skillRepository.createSkill(projectId, storageId, input); + + await this.embedSkillIfAvailable(skill); + return this.skillRepository.getSkill(projectId, skill.id) ?? skill; + } + + renderSkillToMarkdown(projectId: string, skillId: string): string { + const skill = this.requireSkill(projectId, skillId); + return renderSkillMarkdown(skill); + } + + listByStorage(projectId: string, storageId: string, limit?: number): SkillRecord[] { + return this.skillRepository.listSkills(projectId, storageId, limit); + } + + listByAgent(projectId: string, agentPresetId: string, limit = 200): SkillRecord[] { + const storages = this.skillRepository.listStoragesForAgent(projectId, agentPresetId); + const results: SkillRecord[] = []; + for (const storage of storages) { + if (results.length >= limit) { + break; + } + results.push(...this.skillRepository.listSkills(projectId, storage.id, limit - results.length)); + } + return results; + } + + async search(query: SkillSearchQuery): Promise { + const modelId = this.embeddingService.getLoadedModelId(); + if (!modelId) { + return []; + } + + const storageIds = this.resolveSearchStorageIds(query); + if (storageIds.length === 0) { + return []; + } + + const queryEmbedding = await this.embeddingService.embed(query.query); + const dimension = queryEmbedding.length; + const candidates = this.skillRepository.loadEmbeddingsForStorages( + query.projectId, + storageIds, + modelId, + MAX_SEARCH_CANDIDATES, + ); + + const limit = Math.max(1, query.limit ?? 20); + const minSimilarity = query.minSimilarity ?? 0.3; + const topK: Array<{ skillId: string; similarity: number }> = []; + + for (const candidate of candidates) { + if (candidate.embeddingDimension !== dimension) { + continue; + } + const similarity = cosineSimilarity(queryEmbedding, bufferToFloat32(candidate.embeddingBlob, candidate.embeddingDimension)); + if (similarity < minSimilarity) { + continue; + } + const next = { skillId: candidate.skillId, similarity }; + if (topK.length < limit) { + topK.push(next); + topK.sort(compareRankedSkill); + continue; + } + const last = topK[topK.length - 1]!; + if (compareRankedSkill(next, last) < 0) { + topK.pop(); + topK.push(next); + topK.sort(compareRankedSkill); + } + } + + const skills = this.skillRepository.getSkills(query.projectId, topK.map((item) => item.skillId)); + const skillMap = new Map(skills.map((skill) => [skill.id, skill])); + const results: SkillSearchResult[] = []; + for (const item of topK) { + const skill = skillMap.get(item.skillId); + if (skill) { + results.push({ skill, similarity: item.similarity }); + } + } + return results; + } + + private resolveSearchStorageIds(query: SkillSearchQuery): string[] { + if (query.storageId) { + const storage = this.skillRepository.getStorage(query.projectId, query.storageId); + return storage ? [storage.id] : []; + } + if (query.agentPresetId) { + return this.skillRepository.listStoragesForAgent(query.projectId, query.agentPresetId).map((storage) => storage.id); + } + return this.skillRepository.listStorages(query.projectId).map((storage) => storage.id); + } + + private requireSkill(projectId: string, skillId: string): SkillRecord { + const skill = this.skillRepository.getSkill(projectId, skillId); + if (!skill) { + throw new Error(`Skill not found: ${skillId}`); + } + return skill; + } + + private async embedSkillIfAvailable(skill: SkillRecord): Promise { + if (!this.embeddingService.isLoaded()) { + this.skillRepository.deleteEmbeddingsForSkill(skill.projectId, skill.id); + return; + } + const modelId = this.embeddingService.getLoadedModelId(); + if (!modelId) { + return; + } + + try { + const embedding = await this.embeddingService.embed(renderSkillMarkdown(skill)); + this.skillRepository.saveEmbedding(skill.projectId, skill.id, modelId, embedding.length, float32ToBuffer(embedding)); + } catch (error) { + this.logger.warn(`Failed to embed skill ${skill.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } +} + +function compareRankedSkill(a: { skillId: string; similarity: number }, b: { skillId: string; similarity: number }): number { + return b.similarity - a.similarity || a.skillId.localeCompare(b.skillId); +} diff --git a/tests/backend/repositories/skill-repository.test.ts b/tests/backend/repositories/skill-repository.test.ts new file mode 100644 index 0000000000..d389866306 --- /dev/null +++ b/tests/backend/repositories/skill-repository.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it } from "vitest"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { AppDbStorage } from "../../../src/repositories/app-db-storage.js"; +import { AgentPresetRepository } from "../../../src/repositories/agent-preset-repository.js"; +import { ProjectManagementRepository } from "../../../src/repositories/project-management-repository.js"; +import { SkillRepository } from "../../../src/repositories/skill-repository.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +async function createFixture(): Promise<{ + storage: AppDbStorage; + projects: ReturnType[]; + skillRepository: SkillRepository; + agentPresetRepository: AgentPresetRepository; +}> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "code-ux-skill-repo-")); + tempDirs.push(dir); + const storage = new AppDbStorage(path.join(dir, "app.db")); + const projectRepository = new ProjectManagementRepository(storage); + const skillRepository = new SkillRepository(storage); + const agentPresetRepository = new AgentPresetRepository(storage); + const p1 = projectRepository.createProject({ name: "Skill Project 1", sourceType: "local", sourceRef: "/workspace/skill-p1" }); + const p2 = projectRepository.createProject({ name: "Skill Project 2", sourceType: "local", sourceRef: "/workspace/skill-p2" }); + return { storage, projects: [p1, p2], skillRepository, agentPresetRepository }; +} + +describe("SkillRepository", () => { + it("creates, updates, lists, and deletes storage-scoped skills", async () => { + const { skillRepository, projects } = await createFixture(); + const project = projects[0]!; + + const storage = skillRepository.createStorage(project.id, { + id: "core-skills", + name: "Core skills", + description: "Shared implementation guidance.", + }); + expect(storage).toMatchObject({ + id: "core-skills", + projectId: project.id, + name: "Core skills", + storageKind: "project", + }); + + const skill = skillRepository.createSkill(project.id, storage.id, { + id: "error-handling", + name: "Error handling", + description: "Keep failures actionable.", + contentMarkdown: "Return typed errors and preserve project boundaries.", + sourceType: "imported", + sourceRef: "skills/error-handling.md", + tags: ["backend", "backend", "errors"], + appliesTo: ["src/services", "src/repositories"], + version: "1.0.0", + }); + expect(skill).toMatchObject({ + projectId: project.id, + storageId: storage.id, + name: "Error handling", + tags: ["backend", "errors"], + appliesTo: ["src/services", "src/repositories"], + version: "1.0.0", + }); + expect(skill.contentHash).toMatch(/^[a-f0-9]{64}$/); + + const updated = skillRepository.updateSkill(project.id, skill.id, { + name: "Repository errors", + contentMarkdown: "Throw not-found errors for cross-project reads.", + tags: ["repositories"], + appliesTo: ["src/repositories"], + version: null, + }); + expect(updated).toMatchObject({ + name: "Repository errors", + tags: ["repositories"], + appliesTo: ["src/repositories"], + version: null, + }); + expect(updated.contentHash).not.toBe(skill.contentHash); + + expect(skillRepository.listSkills(project.id, storage.id).map((entry) => entry.id)).toEqual([skill.id]); + + skillRepository.deleteSkill(project.id, skill.id); + expect(skillRepository.listSkills(project.id, storage.id)).toEqual([]); + }); + + it("enforces project ownership for storages, skills, and attachments", async () => { + const { skillRepository, agentPresetRepository, projects } = await createFixture(); + const [p1, p2] = projects; + const storage = skillRepository.createStorage(p1!.id, { id: "owned-storage", name: "Owned" }); + const skill = skillRepository.createSkill(p1!.id, storage.id, { + id: "owned-skill", + name: "Owned skill", + contentMarkdown: "Project-owned instructions.", + }); + const p1Agent = agentPresetRepository.createAgentPreset(p1!.id, { id: "agent-p1", name: "P1 Agent" }); + const p2Agent = agentPresetRepository.createAgentPreset(p2!.id, { id: "agent-p2", name: "P2 Agent" }); + + expect(skillRepository.getStorage(p2!.id, storage.id)).toBeNull(); + expect(skillRepository.getSkill(p2!.id, skill.id)).toBeNull(); + expect(() => skillRepository.listSkills(p2!.id, storage.id)).toThrow(/Skill storage not found/); + expect(() => skillRepository.attachStorageToAgent(p2!.id, p2Agent.id, storage.id)).toThrow(/Skill storage not found/); + expect(() => skillRepository.attachStorageToAgent(p1!.id, p2Agent.id, storage.id)).toThrow(/Agent preset not found/); + + const attachment = skillRepository.attachStorageToAgent(p1!.id, p1Agent.id, storage.id); + expect(attachment).toMatchObject({ projectId: p1!.id, agentPresetId: p1Agent.id, storageId: storage.id, enabled: true }); + expect(skillRepository.listStoragesForAgent(p1!.id, p1Agent.id).map((entry) => entry.id)).toEqual([storage.id]); + }); + + it("removes skills, embeddings, and agent bindings when deleting a storage", async () => { + const { storage, skillRepository, agentPresetRepository, projects } = await createFixture(); + const project = projects[0]!; + const skillStorage = skillRepository.createStorage(project.id, { id: "delete-me", name: "Delete me" }); + const skill = skillRepository.createSkill(project.id, skillStorage.id, { + id: "embedded-skill", + name: "Embedded", + contentMarkdown: "Embeddable content.", + }); + skillRepository.saveEmbedding(project.id, skill.id, "test-model", 2, Buffer.from(new Float32Array([1, 0]).buffer)); + const agent = agentPresetRepository.createAgentPreset(project.id, { id: "attached-agent", name: "Attached" }); + skillRepository.attachStorageToAgent(project.id, agent.id, skillStorage.id); + + skillRepository.deleteStorage(project.id, skillStorage.id); + + const db = storage.getDatabase(); + expect(db.prepare("SELECT COUNT(*) AS count FROM skills WHERE storage_id = ?").get(skillStorage.id)).toEqual({ count: 0 }); + expect(db.prepare("SELECT COUNT(*) AS count FROM skill_embeddings WHERE storage_id = ?").get(skillStorage.id)).toEqual({ count: 0 }); + expect(db.prepare("SELECT COUNT(*) AS count FROM agent_skill_storage_bindings WHERE storage_id = ?").get(skillStorage.id)).toEqual({ count: 0 }); + }); +}); diff --git a/tests/backend/services/skill-service.test.ts b/tests/backend/services/skill-service.test.ts new file mode 100644 index 0000000000..c60f95c158 --- /dev/null +++ b/tests/backend/services/skill-service.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, it } from "vitest"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { AppDbStorage } from "../../../src/repositories/app-db-storage.js"; +import { AgentPresetRepository } from "../../../src/repositories/agent-preset-repository.js"; +import { ProjectManagementRepository } from "../../../src/repositories/project-management-repository.js"; +import { SkillRepository } from "../../../src/repositories/skill-repository.js"; +import { SkillService, type SkillEmbeddingProvider } from "../../../src/services/skill-service.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +class FakeEmbeddingProvider implements SkillEmbeddingProvider { + constructor(private loaded = true) {} + + isLoaded(): boolean { + return this.loaded; + } + + getLoadedModelId(): string | null { + return this.loaded ? "fake-2d" : null; + } + + async embed(text: string): Promise { + const lower = text.toLowerCase(); + if (lower.includes("hybrid")) { + return new Float32Array([0.8, 0.2]); + } + if (lower.includes("review")) { + return new Float32Array([1, 0]); + } + if (lower.includes("deploy")) { + return new Float32Array([0, 1]); + } + return new Float32Array([0.4, 0.6]); + } +} + +async function createFixture(embeddingProvider: SkillEmbeddingProvider = new FakeEmbeddingProvider()): Promise<{ + storage: AppDbStorage; + projectId: string; + otherProjectId: string; + skillRepository: SkillRepository; + skillService: SkillService; + agentPresetRepository: AgentPresetRepository; +}> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "code-ux-skill-service-")); + tempDirs.push(dir); + const storage = new AppDbStorage(path.join(dir, "app.db")); + const projectRepository = new ProjectManagementRepository(storage); + const skillRepository = new SkillRepository(storage); + const agentPresetRepository = new AgentPresetRepository(storage); + const project = projectRepository.createProject({ name: "Skill Service Project", sourceType: "local", sourceRef: "/workspace/skill-service" }); + const otherProject = projectRepository.createProject({ name: "Other Skill Service Project", sourceType: "local", sourceRef: "/workspace/skill-service-other" }); + return { + storage, + projectId: project.id, + otherProjectId: otherProject.id, + skillRepository, + skillService: new SkillService(skillRepository, embeddingProvider), + agentPresetRepository, + }; +} + +describe("SkillService", () => { + it("imports and renders skill markdown with frontmatter metadata", async () => { + const { projectId, skillService } = await createFixture(new FakeEmbeddingProvider(false)); + const storage = skillService.createStorage(projectId, { id: "markdown-storage", name: "Markdown storage" }); + + const skill = await skillService.writeSkillFromMarkdown(projectId, storage.id, `--- +title: Review Discipline +description: Keep review findings concrete. +tags: ["review", "quality"] +appliesTo: + - src/services + - tests/backend +version: 2.1.0 +--- + +Focus on bugs, regressions, and missing tests. +`); + + expect(skill).toMatchObject({ + name: "Review Discipline", + description: "Keep review findings concrete.", + tags: ["review", "quality"], + appliesTo: ["src/services", "tests/backend"], + version: "2.1.0", + contentMarkdown: "Focus on bugs, regressions, and missing tests.", + }); + + const rendered = skillService.renderSkillToMarkdown(projectId, skill.id); + expect(rendered).toContain("title: Review Discipline"); + expect(rendered).toContain("description: Keep review findings concrete."); + expect(rendered).toContain('tags: ["review", "quality"]'); + expect(rendered).toContain('appliesTo: ["src/services", "tests/backend"]'); + expect(rendered).toContain("version: 2.1.0"); + expect(rendered).toContain("Focus on bugs, regressions, and missing tests."); + }); + + it("skips embeddings when no provider is available without failing CRUD", async () => { + const { storage, projectId, skillService } = await createFixture(new FakeEmbeddingProvider(false)); + const skillStorage = skillService.createStorage(projectId, { id: "offline-storage", name: "Offline" }); + const skill = await skillService.writeSkillFromMarkdown(projectId, skillStorage.id, `--- +title: Offline Skill +--- + +Store this without embedding. +`); + + expect(skill.name).toBe("Offline Skill"); + expect(storage.getDatabase().prepare("SELECT COUNT(*) AS count FROM skill_embeddings WHERE skill_id = ?").get(skill.id)).toEqual({ count: 0 }); + }); + + it("ranks embedded skill search deterministically and filters mismatched dimensions", async () => { + const { projectId, skillRepository, skillService } = await createFixture(); + const storage = skillService.createStorage(projectId, { id: "search-storage", name: "Search" }); + const review = await skillService.writeSkillFromMarkdown(projectId, storage.id, `--- +title: Review Skill +tags: review +--- + +Review pull requests and record concrete findings. +`); + const hybrid = await skillService.writeSkillFromMarkdown(projectId, storage.id, `--- +title: Hybrid Skill +--- + +Hybrid review and deploy coordination. +`); + const deploy = await skillService.writeSkillFromMarkdown(projectId, storage.id, `--- +title: Deploy Skill +--- + +Deploy release builds after checks pass. +`); + skillRepository.saveEmbedding(projectId, deploy.id, "fake-2d", 3, Buffer.from(new Float32Array([1, 0, 0]).buffer)); + + const results = await skillService.search({ + projectId, + storageId: storage.id, + query: "review", + limit: 5, + minSimilarity: 0, + }); + + expect(results.map((result) => result.skill.id)).toEqual([review.id, hybrid.id]); + expect(results[0]!.similarity).toBeGreaterThan(results[1]!.similarity); + }); + + it("searches only storages attached to the requested agent", async () => { + const { projectId, otherProjectId, agentPresetRepository, skillService } = await createFixture(); + const attachedStorage = skillService.createStorage(projectId, { id: "attached-storage", name: "Attached" }); + const detachedStorage = skillService.createStorage(projectId, { id: "detached-storage", name: "Detached" }); + const otherStorage = skillService.createStorage(otherProjectId, { id: "other-storage", name: "Other" }); + const agent = agentPresetRepository.createAgentPreset(projectId, { id: "skill-agent", name: "Skill Agent" }); + skillService.attachStorageToAgent(projectId, agent.id, attachedStorage.id); + + const attached = await skillService.writeSkillFromMarkdown(projectId, attachedStorage.id, `--- +title: Attached Review +--- + +Review attached storage content. +`); + await skillService.writeSkillFromMarkdown(projectId, detachedStorage.id, `--- +title: Detached Review +--- + +Review detached storage content. +`); + await skillService.writeSkillFromMarkdown(otherProjectId, otherStorage.id, `--- +title: Other Review +--- + +Review another project content. +`); + + expect(skillService.listByAgent(projectId, agent.id).map((skill) => skill.id)).toEqual([attached.id]); + const results = await skillService.search({ + projectId, + agentPresetId: agent.id, + query: "review", + minSimilarity: 0, + limit: 10, + }); + expect(results.map((result) => result.skill.id)).toEqual([attached.id]); + }); +}); From 5015a91daa88ba49fb4320e6d27b4af59d262986 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 01:33:59 +0000 Subject: [PATCH 4/8] feat(task T03): implement via codex --- docs-web/architecture/mcp-server.md | 32 +- docs-web/developer/mcp-tools.md | 60 +++- docs/mcp/runtime-and-dispatch.md | 20 ++ docs/mcp/tools-and-contracts.md | 132 +++++++ src/api/mcp/tool-registry.ts | 4 +- src/app/dependency-factory/core-factory.ts | 12 + .../dependency-factory/dashboard-factory.ts | 1 + src/app/dependency-factory/mcp-factory.ts | 1 + src/contracts/internal-management-types.ts | 43 +++ src/contracts/mcp-tool-definitions.ts | 49 +++ src/mcp/management-tool-handler.ts | 32 +- src/mcp/management/skill-actions.ts | 336 ++++++++++++++++++ src/server/mcp-request-router.ts | 4 +- src/services/skill-service.ts | 25 ++ .../mcp/management-skill-actions.test.ts | 280 +++++++++++++++ tests/backend/mcp/mcp-management.test.ts | 3 + .../backend/mcp/mcp-tool-availability.test.ts | 16 + 17 files changed, 1035 insertions(+), 15 deletions(-) create mode 100644 src/mcp/management/skill-actions.ts create mode 100644 tests/backend/mcp/management-skill-actions.test.ts diff --git a/docs-web/architecture/mcp-server.md b/docs-web/architecture/mcp-server.md index d0afa7429d..37172ca113 100644 --- a/docs-web/architecture/mcp-server.md +++ b/docs-web/architecture/mcp-server.md @@ -72,8 +72,8 @@ This is acceptable because clients are expected to re-`initialize` after restart ## Tool registry The request router (`src/server/mcp-request-router.ts`) is a `name → handler` map populated at boot. -There is **one tool per management domain**, plus `search_knowledge` and the deprecated unified -`manage_code_ux`: +There is **one tool per management domain**, plus retrieval tools such as `search_knowledge` and +`search_skills`, and the deprecated unified `manage_code_ux`: ```ts router @@ -85,10 +85,12 @@ router .register("manage_scheduler", h.handleManageScheduler) .register("manage_agents", h.handleManageAgents) .register("manage_memory", h.handleManageMemory) + .register("manage_skills", h.handleManageSkills) .register("manage_settings", h.handleManageSettings) .register("manage_preview", h.handleManagePreview) .register("manage_telemetry", h.handleManageTelemetry) - .register("search_knowledge", h.handleSearchKnowledge); + .register("search_knowledge", h.handleSearchKnowledge) + .register("search_skills", h.handleSearchSkills); ``` Every tool's input schema is declared in `TOOL_DEFINITIONS` (`src/contracts/mcp-tool-definitions.ts`). @@ -102,16 +104,21 @@ Source: `src/server/mcp-request-router.ts`. ``` Server returns getEnabledToolDefinitions(settings, runtimeRole) ├── Filter by settings.mcpTools[].enabled + ├── Filter by advertised agent Code UX policy, when present └── Filter by tool.runtimeRoles ⊇ runtimeRole ``` +Advertised agent identities fail closed when malformed, unknown, or missing an explicit MCP access +policy. This prevents an unknown worker agent from inheriting broad project-manager tools. + ### `CallTool` ``` 1. Validate tool name against the enabled set. -2. AJV-validate args against TOOL_DEFINITIONS[name].inputSchema. -3. toolRegistry.dispatch(name, args). -4. Wrap handler errors via formatError(). +2. Apply the same per-agent enabled-set filtering used by `ListTools`. +3. AJV-validate args against TOOL_DEFINITIONS[name].inputSchema. +4. toolRegistry.dispatch(name, args). +5. Wrap handler errors via formatError(). ``` Errors: @@ -133,7 +140,9 @@ Each tool has an entry in `settings.mcpTools` (`McpToolToggle[]`). Defaults: { "name": "manage_scheduler", "enabled": true, "isInternal": true }, { "name": "manage_agents", "enabled": true, "isInternal": true }, { "name": "manage_memory", "enabled": true, "isInternal": true }, + { "name": "manage_skills", "enabled": true, "isInternal": true }, { "name": "search_knowledge", "enabled": true, "isInternal": true }, + { "name": "search_skills", "enabled": true, "isInternal": true }, { "name": "manage_settings", "enabled": true, "isInternal": true }, { "name": "manage_preview", "enabled": true, "isInternal": true }, { "name": "manage_telemetry", "enabled": true, "isInternal": true }, @@ -143,6 +152,10 @@ Each tool has an entry in `settings.mcpTools` (`McpToolToggle[]`). Defaults: Disabling a tool removes it from `ListTools` and rejects `CallTool`. +Per-agent overrides are layered over these system toggles. A project can expose `search_skills` to an +agent while disabling `manage_skills`, which gives the agent persistent skill retrieval without +storage mutation, markdown export, delete, or reset authority. + ## Approval handshake Destructive and mutating actions (deletes, resets, settings replacements/patches) are flagged across @@ -158,6 +171,13 @@ single-use. Source: `src/mcp/management-tool-handler.ts`. +## Persistent skill dispatch + +Persistent skills use `SkillService` as the backend boundary. `manage_skills` routes storage CRUD, +skill markdown import/export, agent storage attachment management, and the authoring prompt through +`SkillActions`. `search_skills` is registered separately as a retrieval tool and returns concise +ranked summaries with IDs and metadata. Full markdown retrieval stays behind `manage_skills`. + ## Connection registry The `ConnectionRegistry` tracks every MCP client that connects. Each entry records: diff --git a/docs-web/developer/mcp-tools.md b/docs-web/developer/mcp-tools.md index 9f53333da5..73db31e9be 100644 --- a/docs-web/developer/mcp-tools.md +++ b/docs-web/developer/mcp-tools.md @@ -1,9 +1,9 @@ # MCP tools Code UX is also an MCP server. When connected, it advertises a set of **management tools** that an -MCP client (or another agent) can call to drive projects, sprints, tasks, agents, memory, settings, -previews, and telemetry. This page is the exact contract: the tool list, each tool's `action` enum, -input shape, approval rules, and the error model. +MCP client (or another agent) can call to drive projects, sprints, tasks, agents, memory, persistent +skills, settings, previews, and telemetry. This page is the exact contract: the tool list, each +tool's `action` enum, input shape, approval rules, and the error model. > **Server identity:** the server identifies as `code-ux`, with the version matching the installed > package. The package on npm is `@codeuxai/codeux`. Capabilities advertised at @@ -17,15 +17,19 @@ Tools are filtered before being advertised on `ListTools`: (default and only functional role: `project_manager`). 2. **Toggle** — each tool has an entry under `settings.mcpTools`. Disabled tools are not advertised and return `MethodNotFound` if called. +3. **Per-agent Code UX policy** — HTTP worker clients can advertise an agent preset. Unknown, + malformed, or unconfigured agent identities fail closed and receive no built-in Code UX tools. + Known agents can receive tool-specific overrides; for example, `search_skills` can stay enabled + while `manage_skills` is disabled. All inputs are validated against their declared JSON Schema (AJV) before dispatch; validation failures return `InvalidParams` with the failing JSON path. ## The tools -Code UX exposes **one tool per management domain**, plus `search_knowledge`. Each `manage_*` tool -takes an `action` (from a fixed enum) plus action-specific fields, and an optional `approval` object -for destructive actions. +Code UX exposes **one tool per management domain**, plus retrieval tools such as `search_knowledge` +and `search_skills`. Each `manage_*` tool takes an `action` (from a fixed enum) plus +action-specific fields, and an optional `approval` object for destructive actions. | Tool | Category | Purpose | | --- | --- | --- | @@ -36,7 +40,9 @@ for destructive actions. | `manage_scheduler` | orchestration | Create and run scheduled sprints, quicksprints, and messages. | | `manage_agents` | agents & memory | Manage agent presets and sync them to project markdown. | | `manage_memory` | agents & memory | Inspect, search, promote, and re-embed short/long-term memory. | +| `manage_skills` | agents & memory | Manage persistent skill storages, skill markdown, and agent storage attachments. | | `search_knowledge` | agents & memory | Semantic search over the knowledge base subscribed to the caller. | +| `search_skills` | agents & memory | Semantic retrieval over persistent project skills, optionally scoped to an agent or storage. | | `manage_settings` | platform | Get/resolve/patch/replace/reset system, project, and sprint settings. | | `manage_preview` | platform | Manage sprint preview containers (start/stop/rebuild, logs, scripts). | | `manage_telemetry` | platform | Read execution snapshots, invocations, sprint runs, and dispatches. | @@ -54,6 +60,7 @@ Every tool requires `runtimeRoles: ["project_manager"]` and is enabled by defaul | `manage_scheduler` | `list`, `create`, `update`, `delete`, `run_due`, `schedule_sprint`, `schedule_quicksprint`, `schedule_chat` | | `manage_agents` | `list`, `get`, `create`, `update`, `delete`, `sync` | | `manage_memory` | `list`, `get`, `count`, `create`, `update`, `delete`, `search`, `promote`, `get_map`, `model_status`, `start_reembed` | +| `manage_skills` | `authoring_prompt`, `list_storages`, `get_storage`, `create_storage`, `update_storage`, `delete_storage`, `reset_storage`, `list_agent_storages`, `attach_storage`, `detach_storage`, `list_skills`, `get_skill`, `create_skill`, `update_skill`, `delete_skill`, `import_markdown`, `export_markdown` | | `manage_settings` | `get_system`, `get_project_override`, `resolve_project_effective`, `get_sprint_override`, `resolve_sprint_effective`, `replace_system_settings`, `patch_system_setting`, `replace_project_settings`, `patch_project_setting`, `reset_project_settings`, `replace_sprint_settings`, `patch_sprint_setting`, `reset_sprint_settings` | | `manage_preview` | `list_sessions`, `start_session`, `stop_session`, `rebuild_session`, `remove_session`, `get_logs`, `get_url`, `get_script`, `update_script` | | `manage_telemetry` | `get_project_stats_snapshot`, `get_project_execution_snapshot`, `list_execution_invocations`, `list_execution_invocation_messages`, `list_sprint_runs`, `list_task_dispatches` | @@ -92,6 +99,47 @@ subscriptions, so no project id is needed. Returns the most relevant passages with their source documents. See the [Knowledge](../user/dashboard/knowledge.md) page for managing the underlying documents. +## Persistent skills + +`manage_skills` is the storage and authoring surface for durable project skills. It supports: + +- Storage CRUD: `list_storages`, `get_storage`, `create_storage`, `update_storage`, `delete_storage`. +- Skill CRUD: `list_skills`, `get_skill`, `create_skill`, `update_skill`, `delete_skill`. +- Agent attachment management: `list_agent_storages`, `attach_storage`, `detach_storage`. +- Markdown import/export: `import_markdown`, `export_markdown`. +- Authoring guidance: `authoring_prompt`. +- Destructive cleanup: `delete_storage`, `reset_storage`, and `delete_skill` require the approval handshake. + +Skill markdown is saved through MCP payloads, not by writing files into the project workspace: + +```md +--- +title: Review Discipline +description: Keep review findings concrete. +tags: ["review", "quality"] +appliesTo: ["src/services", "tests/backend"] +version: 1.0.0 +--- + +Focus on bugs, regressions, missing tests, and rollback risk. +``` + +`search_skills` is the retrieval-only surface. It accepts: + +```jsonc +{ + "projectId": "project-123", // required + "query": "review checklist", // required + "agentPresetId": "agent-123", // optional, searches attached storages + "storageId": "skills-review", // optional, narrows to one storage + "limit": 5, // optional, capped by the handler + "minSimilarity": 0.3 // optional, 0-1 +} +``` + +Search results return concise ranked summaries with skill IDs and metadata. Full content retrieval +requires `manage_skills` via `export_markdown` or `get_skill` with `includeContent: true`. + ## Error model Tool handlers return one of: diff --git a/docs/mcp/runtime-and-dispatch.md b/docs/mcp/runtime-and-dispatch.md index 610141fcef..ec2c49f68d 100644 --- a/docs/mcp/runtime-and-dispatch.md +++ b/docs/mcp/runtime-and-dispatch.md @@ -45,9 +45,13 @@ Registered schemas: ### Tool list handler Returns enabled tool definitions from `src/contracts/mcp-tool-definitions.ts`, filtered by dashboard `mcpTools` settings. +When a worker MCP client advertises an agent preset, Code UX resolves that agent's explicit MCP access policy before listing tools. Unknown or malformed agent identities fail closed: `list_tools` returns no built-in Code UX tools, and `call_tool` returns MCP `MethodNotFound`. + ### Tool call handler - Resolves tool name. - Verifies tool is enabled in `mcpTools`. +- Applies the same per-agent Code UX access policy used by `list_tools`. +- Validates tool arguments against the registered JSON schema before dispatch. - Dispatches through typed `ToolRegistry` registration in `src/api/mcp/tool-registry.ts`. - Wraps unknown tool as MCP `MethodNotFound`. - Normalizes runtime/API errors into `isError` response. @@ -68,11 +72,27 @@ This allows all log lines emitted during a tool call to share a single `correlat - Typed registry layer: `src/api/mcp/tool-registry.ts` - Defines strict argument interfaces for every MCP tool. - Provides `register` and `dispatch` APIs with compile-time tool/argument matching. +- Management dispatch target: `ManagementToolHandler` + - Routes dedicated management tools such as `manage_projects`, `manage_memory`, and `manage_skills` to domain action classes. + - Routes retrieval tools such as `search_knowledge` and `search_skills` separately, so agents can receive retrieval without broader management authority. + - Applies stateful approval fingerprints to destructive management actions before mutation. - Core dispatch target: `CoreToolHandler` - Agent dispatch target: `AgentToolHandler` This split keeps tool contracts stable while allowing orchestration internals to evolve independently. +## Persistent Skill Tools + +Persistent skills use `SkillService` as the backend boundary. The MCP layer does not write markdown files into project workspaces and does not duplicate persistence logic; it validates payloads, formats concise responses, and calls the service. + +Runtime behavior: + +- `manage_skills` is a Code UX management tool in the `agents_memory` category. It supports storage CRUD, skill markdown import/export, agent storage attachment management, and the skill-authoring prompt. +- `delete_storage`, `reset_storage`, and `delete_skill` return approval-required envelopes on first call and only mutate on the matching confirmed call. +- `search_skills` is registered as a distinct retrieval tool in the same category. Per-agent MCP policy can disable `manage_skills` while leaving `search_skills` enabled. +- Search scoping is project-owned. `storageId` limits retrieval to one storage; otherwise `agentPresetId` limits retrieval to the agent's attached storages; otherwise all project storages are eligible. +- Search results return ranked summaries with IDs and metadata. Full markdown retrieval remains behind `manage_skills` (`export_markdown` or `get_skill` with `includeContent: true`). + ## Custom MCP Defaults Dashboard settings include custom MCP servers that local CLI providers may receive at execution time. diff --git a/docs/mcp/tools-and-contracts.md b/docs/mcp/tools-and-contracts.md index 91c61771c3..d2c63586a7 100644 --- a/docs/mcp/tools-and-contracts.md +++ b/docs/mcp/tools-and-contracts.md @@ -16,7 +16,9 @@ These cover: - `manage_scheduler` - `manage_agents` - `manage_memory` +- `manage_skills` - `search_knowledge` +- `search_skills` - `manage_settings` - `manage_preview` - `manage_telemetry` @@ -46,7 +48,9 @@ These cover: - `manage_scheduler` - `manage_agents` - `manage_memory` +- `manage_skills` - `search_knowledge` +- `search_skills` - `manage_settings` - `manage_preview` - `manage_telemetry` @@ -207,6 +211,134 @@ For payload normalization in management tools, Code UX centralizes parsing behav The dedicated management tools (`manage_sprints`, `manage_tasks`, `manage_quicksprints`, `manage_scheduler`, `manage_settings`) share the same action handlers. +### `manage_skills` persistent skill actions + +`manage_skills` is the management surface for persistent project skill storage. It is available in the `agents_memory` category for project-manager clients and for agents with explicit Code UX tool access. It is separate from workspace files: callers save skill markdown through the MCP payload, and Code UX writes the durable skill rows and embeddings through `SkillService`. + +Available actions: +- `authoring_prompt`: returns the comprehensive skill-authoring prompt, including markdown/frontmatter format and the workflow for saving skills through `manage_skills` instead of writing into the workspace. +- `list_storages`: requires `projectId`; returns project-owned skill storages. +- `get_storage`: requires `projectId` and `storageId`; returns one project-owned skill storage. +- `create_storage`: requires `projectId` and `name`; accepts `description` and `storageKind` (`project` or `shared`). +- `update_storage`: requires `projectId` and `storageId`; accepts `name`, `description`, and `storageKind`. +- `delete_storage`: requires `projectId` and `storageId`; approval-gated. Deletes the storage, contained skills, embeddings, and agent attachments. +- `reset_storage`: requires `projectId` and `storageId`; approval-gated. Deletes skills and embeddings in the storage while keeping the storage and attachments. +- `list_agent_storages`: requires `projectId` and `agentPresetId`; returns the agent's enabled storage attachments and attached storages. +- `attach_storage`: requires `projectId`, `agentPresetId`, and `storageId`; attaches a project-owned storage to a project-owned agent preset. +- `detach_storage`: requires `projectId`, `agentPresetId`, and `storageId`; removes the attachment. +- `list_skills`: requires `projectId` and `storageId`; accepts `limit`; returns concise skill summaries, not full markdown bodies. +- `get_skill`: requires `projectId` and `skillId`; accepts `includeContent`. By default the response is concise; set `includeContent: true` only when the caller needs the full stored body. +- `create_skill` and `import_markdown`: require `projectId`, `storageId`, and `markdown`; accept `sourceType` (`manual`, `imported`, or `generated`) and nullable `sourceRef`. +- `update_skill`: requires `projectId`, `storageId`, `skillId`, and `markdown`; accepts `sourceType` and nullable `sourceRef`. +- `delete_skill`: requires `projectId` and `skillId`; approval-gated. Deletes the stored markdown and embeddings. +- `export_markdown`: requires `projectId` and `skillId`; returns the full reconstructed markdown with frontmatter. + +Skill markdown uses YAML-like frontmatter followed by the instruction body: + +```md +--- +title: Review Discipline +description: Keep review findings concrete. +tags: ["review", "quality"] +appliesTo: ["src/services", "tests/backend"] +version: 1.0.0 +--- + +Focus on bugs, regressions, missing tests, and rollback risk. +``` + +The parser supports scalar frontmatter fields and simple list forms for `tags` and `appliesTo`. The body is the authoritative instruction content. Metadata is stored in dedicated columns so `export_markdown` can reconstruct the markdown. + +Create or import example: + +```json +{ + "action": "import_markdown", + "projectId": "project-123", + "storageId": "skills-review", + "markdown": "---\ntitle: Review Discipline\ndescription: Keep review findings concrete.\ntags: [\"review\"]\n---\n\nFocus on bugs, regressions, and missing tests." +} +``` + +Approval example for destructive skill deletion: + +```json +{ + "action": "delete_skill", + "projectId": "project-123", + "skillId": "skill-123" +} +``` + +The first call returns `approvalRequired: true`. After human approval, repeat the same request with: + +```json +{ + "action": "delete_skill", + "projectId": "project-123", + "skillId": "skill-123", + "approval": { "confirmed": true } +} +``` + +Project isolation is enforced below the MCP handler by `SkillService` and `SkillRepository`. Storage, skill, embedding, and agent-attachment operations verify the supplied `projectId`; IDs from another project are rejected instead of being read or mutated. + +### `search_skills` retrieval tool + +`search_skills` is the retrieval-focused skill surface. It can be exposed to agents independently from `manage_skills` through per-agent MCP tool filtering. This lets an agent retrieve durable skill guidance without granting it storage creation, mutation, attachment management, export, delete, or reset capabilities. + +Schema: + +```json +{ + "projectId": "project-123", + "query": "review pull request risk checklist", + "agentPresetId": "agent-123", + "storageId": "skills-review", + "limit": 5, + "minSimilarity": 0.3 +} +``` + +Fields: +- `projectId` and non-blank `query` are required. +- `agentPresetId` is optional. When supplied without `storageId`, only storages attached to that project-owned agent are searched. +- `storageId` is optional. When supplied, search is limited to that project-owned storage. +- `limit` defaults to 10 and is capped by the handler. +- `minSimilarity` is optional and must be between 0 and 1 when supplied. + +Response shape: + +```json +{ + "result": { + "results": [ + { + "similarity": 0.91, + "skill": { + "id": "skill-123", + "projectId": "project-123", + "storageId": "skills-review", + "name": "Review Discipline", + "description": "Keep review findings concrete.", + "sourceType": "manual", + "sourceRef": null, + "tags": ["review"], + "appliesTo": ["src/services"], + "version": "1.0.0", + "contentHash": "sha256...", + "createdAt": "2026-07-07T00:00:00.000Z", + "updatedAt": "2026-07-07T00:00:00.000Z", + "summary": "Focus on bugs, regressions, missing tests, and rollback risk." + } + } + ] + } +} +``` + +Search responses intentionally return concise summaries. To retrieve a complete stored skill, call `manage_skills` with `export_markdown`, or call `get_skill` with `includeContent: true` when the caller has management access. + ### `manage_memory` claim actions `manage_memory` supports durable long-term memory claim management in addition to raw memory actions. These actions are available to `project_manager` runtime roles and let project managers create canonical project claims directly without a sprint ID: diff --git a/src/api/mcp/tool-registry.ts b/src/api/mcp/tool-registry.ts index 91a5ba03dc..0fc88543ef 100644 --- a/src/api/mcp/tool-registry.ts +++ b/src/api/mcp/tool-registry.ts @@ -1,5 +1,5 @@ import type { ToolName as ContractToolName } from "../../contracts/mcp-tool-definitions.js"; -import type { ManageCodeUxArgs, ManageProjectsArgs, ManageSprintsArgs, ManageTasksArgs, ManageQuicksprintsArgs, ManageSchedulerArgs, ManageAgentsArgs, ManageMemoryArgs, ManageSettingsArgs, ManagePreviewArgs, ManageTelemetryArgs, SearchKnowledgeArgs } from "../../contracts/internal-management-types.js"; +import type { ManageCodeUxArgs, ManageProjectsArgs, ManageSprintsArgs, ManageTasksArgs, ManageQuicksprintsArgs, ManageSchedulerArgs, ManageAgentsArgs, ManageMemoryArgs, ManageSkillsArgs, ManageSettingsArgs, ManagePreviewArgs, ManageTelemetryArgs, SearchKnowledgeArgs, SearchSkillsArgs } from "../../contracts/internal-management-types.js"; export interface McpToolArgsByName { manage_code_ux: ManageCodeUxArgs; @@ -10,10 +10,12 @@ export interface McpToolArgsByName { manage_scheduler: ManageSchedulerArgs; manage_agents: ManageAgentsArgs; manage_memory: ManageMemoryArgs; + manage_skills: ManageSkillsArgs; manage_settings: ManageSettingsArgs; manage_preview: ManagePreviewArgs; manage_telemetry: ManageTelemetryArgs; search_knowledge: SearchKnowledgeArgs; + search_skills: SearchSkillsArgs; } export type McpToolName = keyof McpToolArgsByName; diff --git a/src/app/dependency-factory/core-factory.ts b/src/app/dependency-factory/core-factory.ts index fadc363c16..0d086c52f6 100644 --- a/src/app/dependency-factory/core-factory.ts +++ b/src/app/dependency-factory/core-factory.ts @@ -34,10 +34,12 @@ import { DockerRuntimePruneService } from "../../services/docker-runtime-prune-s import { DashboardRealtimeService } from "../../services/dashboard-realtime-service.js"; import { MemoryRepository } from "../../repositories/memory-repository.js"; import { SchedulerRepository } from "../../repositories/scheduler-repository.js"; +import { SkillRepository } from "../../repositories/skill-repository.js"; import { EmbeddingService } from "../../services/embedding-service.js"; import { EmbeddingModelManager } from "../../services/embedding-model-manager.js"; import { MemoryService } from "../../services/memory-service.js"; import { MemoryPromotionService } from "../../services/memory-promotion-service.js"; +import { SkillService } from "../../services/skill-service.js"; import { KnowledgeRepository } from "../../repositories/knowledge-repository.js"; import { KnowledgeIngestionService } from "../../services/knowledge-ingestion-service.js"; import { KnowledgeService } from "../../services/knowledge-service.js"; @@ -98,10 +100,12 @@ export interface CoreDependencies { dashboardSettings: DashboardSettings; memoryRepository: MemoryRepository; schedulerRepository: SchedulerRepository; + skillRepository: SkillRepository; embeddingService: EmbeddingService; embeddingModelManager: EmbeddingModelManager; memoryService: MemoryService; memoryPromotionService: MemoryPromotionService; + skillService: SkillService; knowledgeRepository: KnowledgeRepository; knowledgeService: KnowledgeService; providerConcurrencyService: ProviderConcurrencyService; @@ -258,6 +262,7 @@ export function createCoreDependencies( const activitySummary = new ActivitySummaryService(); const memoryRepository = new MemoryRepository(appDbStorage); const schedulerRepository = new SchedulerRepository(appDbStorage, dashboardRealtimeService); + const skillRepository = new SkillRepository(appDbStorage); const embeddingService = new EmbeddingService(); const embeddingModelManager = new EmbeddingModelManager( embeddingService, @@ -276,6 +281,11 @@ export function createCoreDependencies( memoryRepository, logger.child({ component: "memory-promotion-service" }), ); + const skillService = new SkillService( + skillRepository, + embeddingService, + logger.child({ component: "skill-service" }), + ); const knowledgeRepository = new KnowledgeRepository(appDbStorage); const knowledgeService = new KnowledgeService( knowledgeRepository, @@ -336,10 +346,12 @@ export function createCoreDependencies( dashboardSettings, memoryRepository, schedulerRepository, + skillRepository, embeddingService, embeddingModelManager, memoryService, memoryPromotionService, + skillService, knowledgeRepository, knowledgeService, providerConcurrencyService, diff --git a/src/app/dependency-factory/dashboard-factory.ts b/src/app/dependency-factory/dashboard-factory.ts index 085c7408bf..e82f01afc7 100644 --- a/src/app/dependency-factory/dashboard-factory.ts +++ b/src/app/dependency-factory/dashboard-factory.ts @@ -93,6 +93,7 @@ export function createDashboardDependencies( memoryService: coreDeps.memoryService, memoryPromotionService: coreDeps.memoryPromotionService, embeddingModelManager: coreDeps.embeddingModelManager, + skillService: coreDeps.skillService, knowledgeService: coreDeps.knowledgeService, planningAgentService: planningAgentServiceRef, projectSetupService: projectSetupServiceRef, diff --git a/src/app/dependency-factory/mcp-factory.ts b/src/app/dependency-factory/mcp-factory.ts index ab77bb5d26..e9aa055897 100644 --- a/src/app/dependency-factory/mcp-factory.ts +++ b/src/app/dependency-factory/mcp-factory.ts @@ -43,6 +43,7 @@ export function createMcpDependencies( memoryService: coreDeps.memoryService, memoryPromotionService: coreDeps.memoryPromotionService, embeddingModelManager: coreDeps.embeddingModelManager, + skillService: coreDeps.skillService, knowledgeService: coreDeps.knowledgeService, planningAgentService: dashboardDeps.planningAgentService, projectSetupService: dashboardDeps.projectSetupService, diff --git a/src/contracts/internal-management-types.ts b/src/contracts/internal-management-types.ts index 66c8c37617..f17d9f6853 100644 --- a/src/contracts/internal-management-types.ts +++ b/src/contracts/internal-management-types.ts @@ -174,6 +174,49 @@ export interface ManageMemoryArgs { approval?: ManagementApproval; } +export interface ManageSkillsArgs { + action: + | "authoring_prompt" + | "list_storages" + | "get_storage" + | "create_storage" + | "update_storage" + | "delete_storage" + | "reset_storage" + | "list_agent_storages" + | "attach_storage" + | "detach_storage" + | "list_skills" + | "get_skill" + | "create_skill" + | "update_skill" + | "delete_skill" + | "import_markdown" + | "export_markdown"; + projectId?: string; + storageId?: string; + skillId?: string; + agentPresetId?: string; + name?: string; + description?: string; + storageKind?: string; + markdown?: string; + sourceType?: string; + sourceRef?: string | null; + limit?: number; + includeContent?: boolean; + approval?: ManagementApproval; +} + +export interface SearchSkillsArgs { + projectId: string; + query: string; + agentPresetId?: string; + storageId?: string; + limit?: number; + minSimilarity?: number; +} + export interface ManageSettingsArgs { action: "get_system" | "get_project_override" | "resolve_project_effective" | "get_sprint_override" | "resolve_sprint_effective" | "replace_system_settings" | "patch_system_setting" | "replace_project_settings" | "patch_project_setting" | "reset_project_settings" | "replace_sprint_settings" | "patch_sprint_setting" | "reset_sprint_settings"; projectId?: string; diff --git a/src/contracts/mcp-tool-definitions.ts b/src/contracts/mcp-tool-definitions.ts index ac0493b36f..d86f137e02 100644 --- a/src/contracts/mcp-tool-definitions.ts +++ b/src/contracts/mcp-tool-definitions.ts @@ -294,6 +294,37 @@ export const TOOL_DEFINITIONS = [ required: ["action"], }, }, + { + name: "manage_skills", + runtimeRoles: ["project_manager"], + category: "agents_memory", + description: "Manage persistent Code UX skills. Used to create and manage skill storages, attach storages to agents, import/export markdown skills, and retrieve the skill authoring guide. Destructive actions require approval confirmation.", + inputSchema: { + type: "object", + properties: { + action: { type: "string", enum: ["authoring_prompt", "list_storages", "get_storage", "create_storage", "update_storage", "delete_storage", "reset_storage", "list_agent_storages", "attach_storage", "detach_storage", "list_skills", "get_skill", "create_skill", "update_skill", "delete_skill", "import_markdown", "export_markdown"], description: "The skill management action to perform." }, + projectId: { type: "string", description: "Required for project-scoped storage, attachment, and skill actions." }, + storageId: { type: "string", description: "Required for storage update/delete/reset, listing skills, imports, and new skill creation." }, + skillId: { type: "string", description: "Required for get_skill, update_skill, delete_skill, export_markdown, and import_markdown updates." }, + agentPresetId: { type: "string", description: "Required for list_agent_storages, attach_storage, detach_storage, and optional for retrieval scoping." }, + name: { type: "string", description: "Required for create_storage. Optional storage name for update_storage." }, + description: { type: "string", description: "Optional storage description." }, + storageKind: { type: "string", enum: ["project", "shared"], description: "Optional storage kind. Defaults to project." }, + markdown: { type: "string", description: "Required for import_markdown, create_skill, and update_skill. Includes frontmatter plus instruction body." }, + sourceType: { type: "string", enum: ["manual", "imported", "generated"], description: "Optional source type for imported skills." }, + sourceRef: { type: ["string", "null"], description: "Optional source reference for imported skills." }, + limit: { type: "number", description: "Optional result limit for list_skills." }, + includeContent: { type: "boolean", description: "When true, get_skill includes full contentMarkdown. List and search responses stay concise." }, + approval: { + type: "object", + properties: { + confirmed: { type: "boolean" }, + }, + }, + }, + required: ["action"], + }, + }, { name: "search_knowledge", runtimeRoles: ["project_manager"], @@ -309,6 +340,24 @@ export const TOOL_DEFINITIONS = [ required: ["query"], }, }, + { + name: "search_skills", + runtimeRoles: ["project_manager"], + category: "agents_memory", + description: "Search persistent Code UX skills by project, optional agent attachment, or optional storage. Returns concise ranked skill summaries with IDs and metadata; use manage_skills export_markdown or get_skill with includeContent for a full skill.", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string", description: "Project whose persistent skill storage should be searched." }, + query: { type: "string", description: "Natural-language search query describing the skill guidance needed." }, + agentPresetId: { type: "string", description: "Optional agent preset id. When provided without storageId, only that agent's attached storages are searched." }, + storageId: { type: "string", description: "Optional storage id. When provided, search is limited to that project-owned storage." }, + limit: { type: "number", description: "Maximum number of skills to return. Defaults to 10 and is capped by the handler." }, + minSimilarity: { type: "number", description: "Optional minimum cosine similarity threshold from 0 to 1." }, + }, + required: ["projectId", "query"], + }, + }, { name: "manage_settings", runtimeRoles: ["project_manager"], diff --git a/src/mcp/management-tool-handler.ts b/src/mcp/management-tool-handler.ts index 50d6780e28..7303ac414a 100644 --- a/src/mcp/management-tool-handler.ts +++ b/src/mcp/management-tool-handler.ts @@ -8,10 +8,12 @@ import type { ManageSchedulerArgs, ManageAgentsArgs, ManageMemoryArgs, + ManageSkillsArgs, ManageSettingsArgs, ManagePreviewArgs, ManageTelemetryArgs, - SearchKnowledgeArgs + SearchKnowledgeArgs, + SearchSkillsArgs } from "../contracts/internal-management-types.js"; import type { KnowledgeService } from "../services/knowledge-service.js"; import { getCurrentMcpAgentId } from "../server/mcp-agent-context.js"; @@ -26,6 +28,7 @@ import type { AgentPresetSyncService } from "../services/agent-preset-sync-servi import type { MemoryService } from "../services/memory-service.js"; import type { MemoryPromotionService } from "../services/memory-promotion-service.js"; import type { EmbeddingModelManager } from "../services/embedding-model-manager.js"; +import type { SkillService } from "../services/skill-service.js"; import type { PlanningAgentService } from "../services/planning-agent-service.js"; import type { ProjectSetupService } from "../services/project-setup-service.js"; @@ -46,6 +49,7 @@ import { SchedulerActions } from "./management/scheduler-actions.js"; import { SettingsActions } from "./management/settings-actions.js"; import { AgentActions } from "./management/agent-actions.js"; import { MemoryActions } from "./management/memory-actions.js"; +import { SkillActions } from "./management/skill-actions.js"; import { buildMcpApprovalFingerprint, formatManagementErrorEnvelope } from "./management/payload-parsers.js"; import { resolveLateBoundDependency, type LateBoundOrValue } from "../shared/late-bound-dependency.js"; @@ -61,6 +65,7 @@ export interface ManagementToolHandlerDeps { memoryService: MemoryService; memoryPromotionService: MemoryPromotionService; embeddingModelManager: EmbeddingModelManager; + skillService: SkillService; knowledgeService: KnowledgeService; planningAgentService: LateBoundOrValue; projectSetupService?: LateBoundOrValue; @@ -78,12 +83,14 @@ export class ManagementToolHandler { private readonly settingsActions: SettingsActions; private readonly agentActions: AgentActions; private readonly memoryActions: MemoryActions; + private readonly skillActions: SkillActions; private readonly previewActions: PreviewActions; constructor(private readonly deps: ManagementToolHandlerDeps) { this.settingsActions = new SettingsActions(deps.settingsRepository); this.agentActions = new AgentActions(deps.agentPresetSyncService); this.memoryActions = new MemoryActions(deps.memoryService, deps.memoryPromotionService, deps.embeddingModelManager); + this.skillActions = new SkillActions(deps.skillService); this.previewActions = new PreviewActions(deps.sprintPreviewService); } @@ -249,6 +256,8 @@ export class ManagementToolHandler { return this.agentActions.handleAgentAction(args); } else if (args.domain === "memory") { return this.memoryActions.handleMemoryAction(args); + } else if (args.domain === "skills") { + return this.skillActions.handleSkillAction(args); } else if (args.domain === "preview") { const currentHost = null; // serverHost is not available on DashboardSettings, we'll fall back to localhost in preview-origin return this.previewActions.handlePreviewAction(args, currentHost); @@ -376,6 +385,18 @@ export class ManagementToolHandler { } } + async handleManageSkills(args: ManageSkillsArgs): Promise<{ content: Array<{ type: string; text: string }> }> { + try { + const managementArgs = { domain: "skills", action: args.action, payload: args as unknown as Record, approval: args.approval }; + const dispatch = (approval = args.approval) => this.skillActions.handleSkillAction({ ...managementArgs, approval }); + const approvalGate = await this.requireStatefulApproval(managementArgs, () => dispatch({ confirmed: false })); + const envelope = approvalGate ?? this.recordStatefulApprovalRequirement(managementArgs, await dispatch()); + return { content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }] }; + } catch (error) { + return this.formatError("skills", args.action, error); + } + } + async handleManageSettings(args: ManageSettingsArgs): Promise<{ content: Array<{ type: string; text: string }> }> { try { const envelope = await this.settingsActions.handleSettingsAction({ domain: "settings", action: args.action, payload: args as unknown as Record, approval: args.approval }); @@ -438,4 +459,13 @@ export class ManagementToolHandler { return this.formatError("knowledge", "search", error); } } + + async handleSearchSkills(args: SearchSkillsArgs): Promise<{ content: Array<{ type: string; text: string }> }> { + try { + const envelope = await this.skillActions.handleSearchSkills(args); + return { content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }] }; + } catch (error) { + return this.formatError("skills", "search", error); + } + } } diff --git a/src/mcp/management/skill-actions.ts b/src/mcp/management/skill-actions.ts new file mode 100644 index 0000000000..f81a0b1567 --- /dev/null +++ b/src/mcp/management/skill-actions.ts @@ -0,0 +1,336 @@ +import type { ManageCodeUxArgs, ManagementResponseEnvelope, SearchSkillsArgs } from "../../contracts/internal-management-types.js"; +import type { SkillRecord, SkillSearchResult, SkillSourceType, SkillStorageKind } from "../../contracts/skill-types.js"; +import type { SkillService } from "../../services/skill-service.js"; +import { + parseOptionalBoolean, + parseOptionalEnumStrict, + parseOptionalNullableString, + parseOptionalNumber, + parseOptionalString, + parseRequiredString, +} from "./payload-parsers.js"; + +const STORAGE_KINDS = ["project", "shared"] as const satisfies readonly SkillStorageKind[]; +const SOURCE_TYPES = ["manual", "imported", "generated"] as const satisfies readonly SkillSourceType[]; + +const DEFAULT_LIST_LIMIT = 100; +const MAX_LIST_LIMIT = 500; +const DEFAULT_SEARCH_LIMIT = 10; +const MAX_SEARCH_LIMIT = 20; + +export class SkillActions { + constructor(private readonly skillService: SkillService) {} + + async handleSkillAction(args: ManageCodeUxArgs): Promise { + const payload = args.payload || {}; + + switch (args.action) { + case "authoring_prompt": + return this.authoringPrompt(); + case "list_storages": + return this.listStorages(payload); + case "get_storage": + return this.getStorage(payload); + case "create_storage": + return this.createStorage(payload); + case "update_storage": + return this.updateStorage(payload); + case "delete_storage": + return this.deleteStorage(args, payload); + case "reset_storage": + return this.resetStorage(args, payload); + case "list_agent_storages": + return this.listAgentStorages(payload); + case "attach_storage": + return this.attachStorage(payload); + case "detach_storage": + return this.detachStorage(payload); + case "list_skills": + return this.listSkills(payload); + case "get_skill": + return this.getSkill(payload); + case "create_skill": + case "import_markdown": + return this.writeSkillFromMarkdown(payload); + case "update_skill": + return this.writeSkillFromMarkdown(payload, true); + case "delete_skill": + return this.deleteSkill(args, payload); + case "export_markdown": + return this.exportMarkdown(payload); + default: + throw new Error(`Unknown skills action: ${args.action}`); + } + } + + async handleSearchSkills(args: SearchSkillsArgs): Promise { + const payload = args as unknown as Record; + const projectId = parseRequiredString(payload, "projectId"); + const query = parseRequiredString(payload, "query"); + const limit = normalizeLimit(args.limit, DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT); + const minSimilarity = normalizeSimilarity(args.minSimilarity); + const results = await this.skillService.search({ + projectId, + query, + agentPresetId: normalizeOptionalString(args.agentPresetId), + storageId: normalizeOptionalString(args.storageId), + limit, + minSimilarity, + }); + + return { + result: { + results: results.map(formatSearchResult), + }, + }; + } + + private authoringPrompt(): ManagementResponseEnvelope { + return { + result: { + prompt: SKILL_AUTHORING_PROMPT, + }, + }; + } + + private listStorages(payload: Record): ManagementResponseEnvelope { + const projectId = parseRequiredString(payload, "projectId"); + return { result: { storages: this.skillService.listStorages(projectId) } }; + } + + private getStorage(payload: Record): ManagementResponseEnvelope { + const projectId = parseRequiredString(payload, "projectId"); + const storageId = parseRequiredString(payload, "storageId"); + const storage = this.skillService.getStorage(projectId, storageId); + if (!storage) { + throw new Error(`Skill storage not found: ${storageId}`); + } + return { result: { storage } }; + } + + private createStorage(payload: Record): ManagementResponseEnvelope { + const projectId = parseRequiredString(payload, "projectId"); + const storage = this.skillService.createStorage(projectId, { + name: parseRequiredString(payload, "name"), + description: parseOptionalString(payload, "description"), + storageKind: parseOptionalEnumStrict(payload, "storageKind", STORAGE_KINDS), + }); + return { result: { storage } }; + } + + private updateStorage(payload: Record): ManagementResponseEnvelope { + const projectId = parseRequiredString(payload, "projectId"); + const storageId = parseRequiredString(payload, "storageId"); + const storage = this.skillService.updateStorage(projectId, storageId, { + name: parseOptionalString(payload, "name"), + description: parseOptionalString(payload, "description"), + storageKind: parseOptionalEnumStrict(payload, "storageKind", STORAGE_KINDS), + }); + return { result: { storage } }; + } + + private deleteStorage(args: ManageCodeUxArgs, payload: Record): ManagementResponseEnvelope { + const projectId = parseRequiredString(payload, "projectId"); + const storageId = parseRequiredString(payload, "storageId"); + if (args.approval?.confirmed !== true) { + return { + approvalRequired: true, + approvalMessage: `Deleting skill storage ${storageId} removes its skills, embeddings, and agent attachments. Call again with approval.confirmed true after human approval.`, + }; + } + this.skillService.deleteStorage(projectId, storageId); + return { result: { success: true } }; + } + + private async resetStorage(args: ManageCodeUxArgs, payload: Record): Promise { + const projectId = parseRequiredString(payload, "projectId"); + const storageId = parseRequiredString(payload, "storageId"); + if (args.approval?.confirmed !== true) { + return { + approvalRequired: true, + approvalMessage: `Resetting skill storage ${storageId} deletes all skills and embeddings in that storage while keeping the storage and attachments. Call again with approval.confirmed true after human approval.`, + }; + } + const deletedSkills = await this.skillService.resetStorage(projectId, storageId); + return { result: { success: true, deletedSkills } }; + } + + private listAgentStorages(payload: Record): ManagementResponseEnvelope { + const projectId = parseRequiredString(payload, "projectId"); + const agentPresetId = parseRequiredString(payload, "agentPresetId"); + const attachments = this.skillService.listAttachmentsForAgent(projectId, agentPresetId); + const attachedStorageIds = new Set(attachments.map((attachment) => attachment.storageId)); + return { + result: { + attachments, + storages: this.skillService.listStorages(projectId).filter((storage) => attachedStorageIds.has(storage.id)), + }, + }; + } + + private attachStorage(payload: Record): ManagementResponseEnvelope { + const projectId = parseRequiredString(payload, "projectId"); + const agentPresetId = parseRequiredString(payload, "agentPresetId"); + const storageId = parseRequiredString(payload, "storageId"); + this.skillService.attachStorageToAgent(projectId, agentPresetId, storageId); + return { result: { success: true } }; + } + + private detachStorage(payload: Record): ManagementResponseEnvelope { + const projectId = parseRequiredString(payload, "projectId"); + const agentPresetId = parseRequiredString(payload, "agentPresetId"); + const storageId = parseRequiredString(payload, "storageId"); + this.skillService.detachStorageFromAgent(projectId, agentPresetId, storageId); + return { result: { success: true } }; + } + + private listSkills(payload: Record): ManagementResponseEnvelope { + const projectId = parseRequiredString(payload, "projectId"); + const storageId = parseRequiredString(payload, "storageId"); + const limit = normalizeLimit(parseOptionalNumber(payload, "limit"), DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT); + const skills = this.skillService.listByStorage(projectId, storageId, limit).map(formatSkillSummary); + return { result: { skills } }; + } + + private getSkill(payload: Record): ManagementResponseEnvelope { + const projectId = parseRequiredString(payload, "projectId"); + const skillId = parseRequiredString(payload, "skillId"); + const skill = this.skillService.getSkill(projectId, skillId); + if (!skill) { + throw new Error(`Skill not found: ${skillId}`); + } + const includeContent = parseOptionalBoolean(payload, "includeContent") === true; + return { result: { skill: includeContent ? skill : formatSkillSummary(skill) } }; + } + + private async writeSkillFromMarkdown(payload: Record, requireSkillId = false): Promise { + const projectId = parseRequiredString(payload, "projectId"); + const storageId = parseRequiredString(payload, "storageId"); + const skillId = requireSkillId ? parseRequiredString(payload, "skillId") : parseOptionalString(payload, "skillId"); + const markdown = parseRequiredString(payload, "markdown"); + const skill = await this.skillService.writeSkillFromMarkdown(projectId, storageId, markdown, { + skillId, + sourceType: parseOptionalEnumStrict(payload, "sourceType", SOURCE_TYPES), + sourceRef: parseOptionalNullableString(payload, "sourceRef"), + }); + return { result: { skill: formatSkillSummary(skill) } }; + } + + private async deleteSkill(args: ManageCodeUxArgs, payload: Record): Promise { + const projectId = parseRequiredString(payload, "projectId"); + const skillId = parseRequiredString(payload, "skillId"); + if (args.approval?.confirmed !== true) { + return { + approvalRequired: true, + approvalMessage: `Deleting skill ${skillId} removes its stored markdown and embeddings. Call again with approval.confirmed true after human approval.`, + }; + } + await this.skillService.deleteSkill(projectId, skillId); + return { result: { success: true } }; + } + + private exportMarkdown(payload: Record): ManagementResponseEnvelope { + const projectId = parseRequiredString(payload, "projectId"); + const skillId = parseRequiredString(payload, "skillId"); + return { result: { markdown: this.skillService.renderSkillToMarkdown(projectId, skillId) } }; + } +} + +function normalizeLimit(value: number | undefined, defaultValue: number, maxValue: number): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return defaultValue; + } + return Math.min(Math.floor(value), maxValue); +} + +function normalizeSimilarity(value: number | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1 ? value : undefined; +} + +function normalizeOptionalString(value: string | undefined): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function formatSkillSummary(skill: SkillRecord): Record { + return { + id: skill.id, + projectId: skill.projectId, + storageId: skill.storageId, + name: skill.name, + description: skill.description, + sourceType: skill.sourceType, + sourceRef: skill.sourceRef, + tags: skill.tags, + appliesTo: skill.appliesTo, + version: skill.version, + contentHash: skill.contentHash, + createdAt: skill.createdAt, + updatedAt: skill.updatedAt, + summary: summarizeMarkdown(skill.contentMarkdown), + }; +} + +function formatSearchResult(result: SkillSearchResult): Record { + return { + similarity: result.similarity, + skill: formatSkillSummary(result.skill), + }; +} + +function summarizeMarkdown(markdown: string): string { + const normalized = markdown.replace(/\s+/g, " ").trim(); + if (normalized.length <= 240) { + return normalized; + } + return `${normalized.slice(0, 237).trimEnd()}...`; +} + +const SKILL_AUTHORING_PROMPT = `You are authoring a persistent Code UX skill. + +Purpose: +- A skill is durable operational guidance that agents can retrieve later through search_skills. +- Store reusable procedures, review checklists, domain conventions, troubleshooting playbooks, or project-specific engineering rules. +- Do not write skill files into the project workspace. Save them to persistent Code UX skill storage through manage_skills. + +Markdown format: +\`\`\`md +--- +title: Short Skill Name +description: One sentence describing when to use this skill. +tags: ["review", "testing", "backend"] +appliesTo: ["src/services", "tests/backend"] +version: 1.0.0 +--- + +Write the actual skill instructions here. + +Use concise headings, concrete steps, validation commands, known pitfalls, and examples when they help future agents act correctly. +\`\`\` + +Frontmatter fields: +- title: Required by convention. Used as the persistent skill name. If omitted, Code UX stores "Untitled skill". +- description: Optional concise retrieval hint. +- tags: Optional list of searchable labels. +- appliesTo: Optional list of paths, modules, subsystems, or agent roles the skill applies to. +- version: Optional skill version string. + +Authoring guidance: +- Keep instructions durable. Avoid one-off task status, temporary branch names, secrets, credentials, or private customer names. +- Prefer specific procedures over broad advice. +- Include verification commands only when they are stable for this repository or subsystem. +- Keep the body focused enough that search_skills can return a useful summary. + +Saving workflow: +1. Find or create a storage with manage_skills list_storages or create_storage. +2. Save the markdown with manage_skills import_markdown or create_skill using projectId, storageId, and markdown. +3. Attach the storage to an agent with manage_skills attach_storage when that agent should retrieve the skill. +4. Use search_skills with projectId and optional agentPresetId to verify retrieval. + +Updating workflow: +1. Export the current markdown with manage_skills export_markdown using projectId and skillId. +2. Edit the markdown content in the tool payload, not as a workspace file. +3. Save it with manage_skills update_skill or import_markdown with skillId, projectId, storageId, and markdown.`; diff --git a/src/server/mcp-request-router.ts b/src/server/mcp-request-router.ts index 5f1ba4bbe3..cbcd4e11d8 100644 --- a/src/server/mcp-request-router.ts +++ b/src/server/mcp-request-router.ts @@ -37,10 +37,12 @@ export const registerMcpRequestHandlers = (args: McpRequestRouterArgs): void => .register("manage_scheduler", async (input) => (await args.managementToolHandler.handleManageScheduler(input)) as McpToolResponse) .register("manage_agents", async (input) => (await args.managementToolHandler.handleManageAgents(input)) as McpToolResponse) .register("manage_memory", async (input) => (await args.managementToolHandler.handleManageMemory(input)) as McpToolResponse) + .register("manage_skills", async (input) => (await args.managementToolHandler.handleManageSkills(input)) as McpToolResponse) .register("manage_settings", async (input) => (await args.managementToolHandler.handleManageSettings(input)) as McpToolResponse) .register("manage_preview", async (input) => (await args.managementToolHandler.handleManagePreview(input)) as McpToolResponse) .register("manage_telemetry", async (input) => (await args.managementToolHandler.handleManageTelemetry(input)) as McpToolResponse) - .register("search_knowledge", async (input) => (await args.managementToolHandler.handleSearchKnowledge(input)) as McpToolResponse); + .register("search_knowledge", async (input) => (await args.managementToolHandler.handleSearchKnowledge(input)) as McpToolResponse) + .register("search_skills", async (input) => (await args.managementToolHandler.handleSearchSkills(input)) as McpToolResponse); const denyAllCodeUxTools: AgentCodeUxToolAccess = { codeUxEnabled: false, diff --git a/src/services/skill-service.ts b/src/services/skill-service.ts index 42de896e39..97402cf0a1 100644 --- a/src/services/skill-service.ts +++ b/src/services/skill-service.ts @@ -10,6 +10,7 @@ import type { SkillSourceType, SkillStorageRecord, UpdateSkillStorageInput, + AgentSkillStorageAttachment, } from "../contracts/skill-types.js"; export interface SkillEmbeddingProvider { @@ -41,6 +42,10 @@ export class SkillService { return this.skillRepository.listStorages(projectId); } + getStorage(projectId: string, storageId: string): SkillStorageRecord | null { + return this.skillRepository.getStorage(projectId, storageId); + } + updateStorage(projectId: string, storageId: string, input: UpdateSkillStorageInput): SkillStorageRecord { return this.skillRepository.updateStorage(projectId, storageId, input); } @@ -57,6 +62,10 @@ export class SkillService { this.skillRepository.detachStorageFromAgent(projectId, agentPresetId, storageId); } + listAttachmentsForAgent(projectId: string, agentPresetId: string): AgentSkillStorageAttachment[] { + return this.skillRepository.listAttachmentsForAgent(projectId, agentPresetId); + } + async writeSkillFromMarkdown( projectId: string, storageId: string, @@ -89,6 +98,10 @@ export class SkillService { return renderSkillMarkdown(skill); } + getSkill(projectId: string, skillId: string): SkillRecord | null { + return this.skillRepository.getSkill(projectId, skillId); + } + listByStorage(projectId: string, storageId: string, limit?: number): SkillRecord[] { return this.skillRepository.listSkills(projectId, storageId, limit); } @@ -105,6 +118,18 @@ export class SkillService { return results; } + async deleteSkill(projectId: string, skillId: string): Promise { + this.skillRepository.deleteSkill(projectId, skillId); + } + + async resetStorage(projectId: string, storageId: string): Promise { + const skills = this.skillRepository.listSkills(projectId, storageId, MAX_SEARCH_CANDIDATES); + for (const skill of skills) { + this.skillRepository.deleteSkill(projectId, skill.id); + } + return skills.length; + } + async search(query: SkillSearchQuery): Promise { const modelId = this.embeddingService.getLoadedModelId(); if (!modelId) { diff --git a/tests/backend/mcp/management-skill-actions.test.ts b/tests/backend/mcp/management-skill-actions.test.ts new file mode 100644 index 0000000000..09ae81751a --- /dev/null +++ b/tests/backend/mcp/management-skill-actions.test.ts @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, it } from "vitest"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { SkillActions } from "../../../src/mcp/management/skill-actions.js"; +import { AppDbStorage } from "../../../src/repositories/app-db-storage.js"; +import { AgentPresetRepository } from "../../../src/repositories/agent-preset-repository.js"; +import { ProjectManagementRepository } from "../../../src/repositories/project-management-repository.js"; +import { SkillRepository } from "../../../src/repositories/skill-repository.js"; +import { SkillService, type SkillEmbeddingProvider } from "../../../src/services/skill-service.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +class FakeEmbeddingProvider implements SkillEmbeddingProvider { + isLoaded(): boolean { + return true; + } + + getLoadedModelId(): string | null { + return "fake-2d"; + } + + async embed(text: string): Promise { + const lower = text.toLowerCase(); + if (lower.includes("review")) { + return new Float32Array([1, 0]); + } + if (lower.includes("deploy")) { + return new Float32Array([0, 1]); + } + return new Float32Array([0.5, 0.5]); + } +} + +async function createFixture(): Promise<{ + projectId: string; + otherProjectId: string; + agentPresetId: string; + actions: SkillActions; + skillService: SkillService; +}> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "code-ux-management-skills-")); + tempDirs.push(dir); + const storage = new AppDbStorage(path.join(dir, "app.db")); + const projectRepository = new ProjectManagementRepository(storage); + const agentPresetRepository = new AgentPresetRepository(storage); + const skillRepository = new SkillRepository(storage); + const skillService = new SkillService(skillRepository, new FakeEmbeddingProvider()); + const project = projectRepository.createProject({ name: "Skill MCP Project", sourceType: "local", sourceRef: "/workspace/skill-mcp" }); + const otherProject = projectRepository.createProject({ name: "Other Skill MCP Project", sourceType: "local", sourceRef: "/workspace/skill-mcp-other" }); + const agent = agentPresetRepository.createAgentPreset(project.id, { id: "review-agent", name: "Review Agent" }); + return { + projectId: project.id, + otherProjectId: otherProject.id, + agentPresetId: agent.id, + actions: new SkillActions(skillService), + skillService, + }; +} + +describe("SkillActions", () => { + it("returns the authoring prompt with persistent storage instructions", async () => { + const { actions } = await createFixture(); + const response = await actions.handleSkillAction({ domain: "skills", action: "authoring_prompt", payload: {} }); + const prompt = (response.result as { prompt: string }).prompt; + + expect(prompt).toContain("Frontmatter fields"); + expect(prompt).toContain("manage_skills import_markdown"); + expect(prompt).toContain("Do not write skill files into the project workspace"); + }); + + it("rejects validation failures before calling storage actions", async () => { + const { actions, projectId } = await createFixture(); + + await expect(actions.handleSkillAction({ + domain: "skills", + action: "create_storage", + payload: { projectId, name: "Bad", storageKind: "remote" }, + })).rejects.toThrow("Invalid value for storageKind"); + + await expect(actions.handleSkillAction({ + domain: "skills", + action: "list_skills", + payload: { storageId: "missing-project" }, + })).rejects.toThrow("projectId is required"); + }); + + it("creates, updates, exports, and searches skills with concise retrieval results", async () => { + const { actions, projectId } = await createFixture(); + const storageResponse = await actions.handleSkillAction({ + domain: "skills", + action: "create_storage", + payload: { projectId, name: "Review Skills", description: "Reusable review guidance" }, + }); + const storageId = ((storageResponse.result as { storage: { id: string } }).storage.id); + const getStorageResponse = await actions.handleSkillAction({ + domain: "skills", + action: "get_storage", + payload: { projectId, storageId }, + }); + expect((getStorageResponse.result as { storage: { name: string } }).storage.name).toBe("Review Skills"); + + const createResponse = await actions.handleSkillAction({ + domain: "skills", + action: "create_skill", + payload: { + projectId, + storageId, + markdown: `--- +title: Review Discipline +description: Find concrete regressions. +tags: ["review"] +--- + +Review pull requests for bugs, regressions, and missing tests. +`, + }, + }); + const skillId = ((createResponse.result as { skill: { id: string } }).skill.id); + + const updateResponse = await actions.handleSkillAction({ + domain: "skills", + action: "update_skill", + payload: { + projectId, + storageId, + skillId, + markdown: `--- +title: Review Discipline +description: Find concrete regressions. +tags: ["review", "quality"] +version: 1.1.0 +--- + +Review pull requests for bugs, regressions, missing tests, and unclear rollback paths. +`, + }, + }); + expect((updateResponse.result as { skill: { version: string } }).skill.version).toBe("1.1.0"); + + const exportResponse = await actions.handleSkillAction({ + domain: "skills", + action: "export_markdown", + payload: { projectId, skillId }, + }); + expect((exportResponse.result as { markdown: string }).markdown).toContain("title: Review Discipline"); + + const searchResponse = await actions.handleSearchSkills({ + projectId, + storageId, + query: "review", + minSimilarity: 0, + limit: 5, + }); + const results = (searchResponse.result as { results: Array<{ skill: Record }> }).results; + expect(results).toHaveLength(1); + expect(results[0]!.skill.id).toBe(skillId); + expect(results[0]!.skill).not.toHaveProperty("contentMarkdown"); + expect(results[0]!.skill.summary).toContain("Review pull requests"); + }); + + it("enforces project isolation through the skill service boundary", async () => { + const { actions, projectId, otherProjectId, skillService } = await createFixture(); + const storage = skillService.createStorage(projectId, { id: "owned-storage", name: "Owned" }); + await skillService.writeSkillFromMarkdown(projectId, storage.id, `--- +title: Owned Skill +--- + +Review owned project content. +`); + + await expect(actions.handleSkillAction({ + domain: "skills", + action: "list_skills", + payload: { projectId: otherProjectId, storageId: storage.id }, + })).rejects.toThrow("Skill storage not found"); + }); + + it("requires approval for delete and reset flows before mutating", async () => { + const { actions, projectId, skillService } = await createFixture(); + const storage = skillService.createStorage(projectId, { id: "reset-storage", name: "Reset" }); + const skill = await skillService.writeSkillFromMarkdown(projectId, storage.id, `--- +title: Delete Me +--- + +Delete this skill. +`); + + const deletePrompt = await actions.handleSkillAction({ + domain: "skills", + action: "delete_skill", + payload: { projectId, skillId: skill.id }, + }); + expect(deletePrompt.approvalRequired).toBe(true); + expect(skillService.getSkill(projectId, skill.id)).not.toBeNull(); + + await actions.handleSkillAction({ + domain: "skills", + action: "delete_skill", + payload: { projectId, skillId: skill.id }, + approval: { confirmed: true }, + }); + expect(skillService.getSkill(projectId, skill.id)).toBeNull(); + + await skillService.writeSkillFromMarkdown(projectId, storage.id, `--- +title: Reset Me +--- + +Reset this skill. +`); + const resetPrompt = await actions.handleSkillAction({ + domain: "skills", + action: "reset_storage", + payload: { projectId, storageId: storage.id }, + }); + expect(resetPrompt.approvalRequired).toBe(true); + expect(skillService.listByStorage(projectId, storage.id)).toHaveLength(1); + + const resetResponse = await actions.handleSkillAction({ + domain: "skills", + action: "reset_storage", + payload: { projectId, storageId: storage.id }, + approval: { confirmed: true }, + }); + expect(resetResponse.result).toEqual({ success: true, deletedSkills: 1 }); + expect(skillService.listByStorage(projectId, storage.id)).toEqual([]); + }); + + it("manages agent storage attachments and searches only attached storages", async () => { + const { actions, projectId, agentPresetId, skillService } = await createFixture(); + const attached = skillService.createStorage(projectId, { id: "attached", name: "Attached" }); + const detached = skillService.createStorage(projectId, { id: "detached", name: "Detached" }); + const attachedSkill = await skillService.writeSkillFromMarkdown(projectId, attached.id, `--- +title: Attached Review +--- + +Review attached content. +`); + await skillService.writeSkillFromMarkdown(projectId, detached.id, `--- +title: Detached Review +--- + +Review detached content. +`); + + await actions.handleSkillAction({ + domain: "skills", + action: "attach_storage", + payload: { projectId, agentPresetId, storageId: attached.id }, + }); + + const listResponse = await actions.handleSkillAction({ + domain: "skills", + action: "list_agent_storages", + payload: { projectId, agentPresetId }, + }); + expect((listResponse.result as { storages: Array<{ id: string }> }).storages.map((storage) => storage.id)).toEqual([attached.id]); + + const searchResponse = await actions.handleSearchSkills({ + projectId, + agentPresetId, + query: "review", + minSimilarity: 0, + limit: 10, + }); + const resultIds = (searchResponse.result as { results: Array<{ skill: { id: string } }> }).results.map((result) => result.skill.id); + expect(resultIds).toEqual([attachedSkill.id]); + + await actions.handleSkillAction({ + domain: "skills", + action: "detach_storage", + payload: { projectId, agentPresetId, storageId: attached.id }, + }); + expect(skillService.listByAgent(projectId, agentPresetId)).toEqual([]); + }); +}); diff --git a/tests/backend/mcp/mcp-management.test.ts b/tests/backend/mcp/mcp-management.test.ts index ee889177ab..78b535b9af 100644 --- a/tests/backend/mcp/mcp-management.test.ts +++ b/tests/backend/mcp/mcp-management.test.ts @@ -82,6 +82,9 @@ describe("ManagementToolHandler", () => { embeddingModelManager: { getModelStatus: vi.fn(), }, + skillService: { + listStorages: vi.fn(), + }, planningAgentService: { planSprint: vi.fn(), }, diff --git a/tests/backend/mcp/mcp-tool-availability.test.ts b/tests/backend/mcp/mcp-tool-availability.test.ts index eaafa4895c..9e66d46108 100644 --- a/tests/backend/mcp/mcp-tool-availability.test.ts +++ b/tests/backend/mcp/mcp-tool-availability.test.ts @@ -13,11 +13,15 @@ describe("tool availability", () => { expect(projectManagerTools.some((tool) => tool.name === "manage_tasks")).toBe(true); expect(projectManagerTools.some((tool) => tool.name === "manage_quicksprints")).toBe(true); expect(projectManagerTools.some((tool) => tool.name === "manage_scheduler")).toBe(true); + expect(projectManagerTools.some((tool) => tool.name === "manage_skills")).toBe(true); + expect(projectManagerTools.some((tool) => tool.name === "search_skills")).toBe(true); expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "manage_code_ux", "project_manager")).toBe(true); expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "manage_projects", "project_manager")).toBe(true); expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "manage_sprints", "project_manager")).toBe(true); expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "manage_quicksprints", "project_manager")).toBe(true); expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "manage_scheduler", "project_manager")).toBe(true); + expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "manage_skills", "project_manager")).toBe(true); + expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "search_skills", "project_manager")).toBe(true); expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "claim_attention_item", "project_manager" as any)).toBe(false); expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "execute_worker_dispatch", "project_manager" as any)).toBe(false); }); @@ -63,6 +67,18 @@ describe("tool availability", () => { expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "manage_projects", "project_manager", agentToggles)).toBe(true); }); + it("allows per-agent policy to expose skill retrieval without skill management", () => { + const agentToggles = [ + { name: "manage_skills", enabled: false, isInternal: true }, + { name: "search_skills", enabled: true, isInternal: true }, + ]; + const names = getEnabledToolDefinitions(DEFAULT_DASHBOARD_SETTINGS, "project_manager", agentToggles).map((tool) => tool.name); + expect(names).not.toContain("manage_skills"); + expect(names).toContain("search_skills"); + expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "manage_skills", "project_manager", agentToggles)).toBe(false); + expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "search_skills", "project_manager", agentToggles)).toBe(true); + }); + it("sanitizes toggles and ignores unknown tool names", () => { const sanitized = sanitizeMcpToolToggles([ { name: "manage_tasks", enabled: false }, From b6fbe45b961d30245764cb4f434da06ab0d6f48d Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 01:50:57 +0000 Subject: [PATCH 5/8] feat(task T06): implement via codex --- dashboard/src/v2/AgentsPage.tsx | 19 +- .../agents/AgentPresetDetailPanel.tsx | 38 +- .../agents/AgentPresetEditorPanel.tsx | 81 +++- .../settings/SettingsCategoryRail.tsx | 2 +- .../v2/components/settings/panels/QAPanel.tsx | 196 +++++++++- .../settings/panels/SettingsAgentsPanel.tsx | 357 +++++++++++++++++- .../settings/panels/SettingsSprintPanel.tsx | 36 ++ .../src/v2/hooks/use-settings-page-state.ts | 10 +- dashboard/src/v2/lib/agent-preset-api.ts | 28 ++ dashboard/src/v2/lib/settings-search-index.ts | 9 + dashboard/src/v2/types.ts | 6 + docs-web/user/dashboard/agents.md | 6 +- docs-web/user/dashboard/settings.md | 10 + docs/dashboard/design-system-agents.md | 7 + docs/dashboard/design-system-settings.md | 4 + .../lib/settings-search-index.test.ts | 6 + tests/dashboard/v2/agents-page.test.tsx | 55 +++ .../dashboard/v2/settings-page-state.test.tsx | 36 ++ 18 files changed, 892 insertions(+), 14 deletions(-) diff --git a/dashboard/src/v2/AgentsPage.tsx b/dashboard/src/v2/AgentsPage.tsx index 52cde9c3c4..725710ee3a 100644 --- a/dashboard/src/v2/AgentsPage.tsx +++ b/dashboard/src/v2/AgentsPage.tsx @@ -2,7 +2,7 @@ import type { FunctionComponent } from "preact"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import gsap from "gsap"; import { Bot, Plus, Info, ShieldCheck, AlertTriangle, Database, FileText, CheckCircle2, GitBranch, Loader2, ExternalLink } from "lucide-preact"; -import type { AgentPreset } from "./types.js"; +import type { AgentPreset, SkillStorageRecord } from "./types.js"; import type { InstructionFileSummary, InstructionFileContent } from "./lib/instruction-file-api.js"; import { fetchInstructionFiles } from "./lib/instruction-file-api.js"; import { useProjectData } from "./context/project-data.js"; @@ -10,6 +10,7 @@ import { createAgentPreset, deleteAgentPreset, fetchAgentPresets, + fetchSkillStorages, importAgentPresetFromMarkdown, pushAgentPresetsToRepository, syncAllAgentPresetsFromMarkdown, @@ -114,6 +115,7 @@ export const AgentsPage: FunctionComponent = () => { const [selectedAgentUsageLoading, setSelectedAgentUsageLoading] = useState(false); const [isEditing, setIsEditing] = useState(false); const [instructionFiles, setInstructionFiles] = useState([]); + const [skillStorages, setSkillStorages] = useState([]); const [selectedFileId, setSelectedFileId] = useState(null); const { data: effectiveSettings, @@ -171,10 +173,23 @@ export const AgentsPage: FunctionComponent = () => { } }; + const refreshSkillStorages = async (): Promise => { + if (!selectedProject) { + setSkillStorages([]); + return; + } + try { + setSkillStorages(await fetchSkillStorages(selectedProject.id)); + } catch { + setSkillStorages([]); + } + }; + useEffect(() => { setSelectedFileId(null); void refreshPresets(); void refreshInstructionFiles(); + void refreshSkillStorages(); }, [selectedProject?.id]); const handleInstructionFileSaved = (updated: InstructionFileContent): void => { @@ -858,6 +873,7 @@ export const AgentsPage: FunctionComponent = () => { defaultMemoryInstruction={effectiveSettings?.settings.memory.workerLearningsInstruction || ""} providerOptions={providerOptions} availableMcpServers={availableMcpServers} + availableSkillStorages={skillStorages} onSave={handleSave} onCancel={() => setIsEditing(false)} /> @@ -867,6 +883,7 @@ export const AgentsPage: FunctionComponent = () => { routeTags={routeTagsByPresetId.get(selectedPreset.id) ?? []} providerOptions={providerOptions} availableMcpServers={availableMcpServers} + availableSkillStorages={skillStorages} usageSummary={selectedAgentUsage} usageLoading={selectedAgentUsageLoading} onEdit={() => setIsEditing(true)} diff --git a/dashboard/src/v2/components/agents/AgentPresetDetailPanel.tsx b/dashboard/src/v2/components/agents/AgentPresetDetailPanel.tsx index b6441d1b67..ab57c6c62d 100644 --- a/dashboard/src/v2/components/agents/AgentPresetDetailPanel.tsx +++ b/dashboard/src/v2/components/agents/AgentPresetDetailPanel.tsx @@ -11,7 +11,7 @@ import { fetchKnowledgeDocuments, type KnowledgeDocument, } from "../../lib/knowledge-api.js"; -import type { AgentPreset, CustomMcpServer } from "../../types.js"; +import type { AgentPreset, CustomMcpServer, SkillStorageRecord } from "../../types.js"; import type { AgentProviderOption } from "./AgentPresetEditorPanel.js"; import type { AgentAvatarExpression } from "../../lib/agent-avatar.js"; import { AgentAvatarStage } from "./AgentAvatarStage.js"; @@ -182,6 +182,7 @@ export const AgentPresetDetailPanel: FunctionComponent<{ routeTags: string[]; providerOptions?: AgentProviderOption[]; availableMcpServers?: CustomMcpServer[]; + availableSkillStorages?: SkillStorageRecord[]; usageSummary?: AgentUsageSummary | null; usageLoading?: boolean; onEdit: () => void; @@ -194,6 +195,7 @@ export const AgentPresetDetailPanel: FunctionComponent<{ routeTags, providerOptions = [], availableMcpServers = [], + availableSkillStorages = [], usageSummary, usageLoading = false, onEdit, @@ -214,6 +216,10 @@ export const AgentPresetDetailPanel: FunctionComponent<{ const mcpTags = resolveAgentMcpTags(preset.mcpAccess, availableMcpServers); const visibleMcpTags = mcpTags.slice(0, 6); const hiddenMcpTagCount = mcpTags.length - visibleMcpTags.length; + const attachedSkillStorages = (preset.persistentSkillStorageIds ?? []) + .map((storageId) => availableSkillStorages.find((storage) => storage.id === storageId) ?? null) + .filter((storage): storage is SkillStorageRecord => Boolean(storage)); + const persistentSkillsActive = Boolean(preset.persistentSkillStorage?.enabled && attachedSkillStorages.length > 0); useLayoutEffect(() => { if (!panelRef.current) return; @@ -397,6 +403,36 @@ export const AgentPresetDetailPanel: FunctionComponent<{ {/* Knowledge subscriptions */} + {/* Persistent skills */} +
+ +
+
+
+ Persistent skill retrieval is separate from memory and knowledge documents. +
+ + {persistentSkillsActive ? "Enabled" : "Default off"} + +
+
+ {attachedSkillStorages.length === 0 ? ( + + No storage attached + + ) : attachedSkillStorages.map((storage) => ( + + + {storage.name} + + ))} +
+
+
+ {/* System Instructions */}
) => void; onCancel: () => void; -}> = ({ preset, saving, defaultMemoryInstruction = "", providerOptions = [], availableMcpServers = [], onSave, onCancel }) => { +}> = ({ preset, saving, defaultMemoryInstruction = "", providerOptions = [], availableMcpServers = [], availableSkillStorages = [], onSave, onCancel }) => { const panelRef = useRef(null); const nameRef = useRef(null); const descriptionRef = useRef(null); @@ -242,6 +244,8 @@ export const AgentPresetEditorPanel: FunctionComponent<{ const [memoryConfig, setMemoryConfig] = useState( preset.memoryConfig ?? DEFAULT_AGENT_MEMORY_CONFIG ); + const [persistentSkillStorageIds, setPersistentSkillStorageIds] = useState(preset.persistentSkillStorageIds ?? []); + const [persistentSkillsEnabled, setPersistentSkillsEnabled] = useState(Boolean(preset.persistentSkillStorage?.enabled)); const [showMemoryPanel, setShowMemoryPanel] = useState(false); const memoryButtonRef = useRef(null); const [touched, setTouched] = useState>({}); @@ -275,6 +279,8 @@ export const AgentPresetEditorPanel: FunctionComponent<{ setAvatarConfig(preset.avatarConfig); setMcpAccess(normalizeAgentMcpAccess(preset.mcpAccess ?? defaultAgentMcpAccess())); setMemoryConfig(preset.memoryConfig ?? DEFAULT_AGENT_MEMORY_CONFIG); + setPersistentSkillStorageIds(preset.persistentSkillStorageIds ?? []); + setPersistentSkillsEnabled(Boolean(preset.persistentSkillStorage?.enabled)); setShowMemoryPanel(false); setTouched({}); setKnowledgeDirty(false); @@ -314,6 +320,8 @@ export const AgentPresetEditorPanel: FunctionComponent<{ if (JSON.stringify(avatarConfig ?? {}) !== JSON.stringify(preset.avatarConfig ?? {})) return true; if (JSON.stringify(mcpAccess) !== JSON.stringify(normalizeAgentMcpAccess(preset.mcpAccess ?? defaultAgentMcpAccess()))) return true; if (JSON.stringify(memoryConfig) !== JSON.stringify(preset.memoryConfig ?? DEFAULT_AGENT_MEMORY_CONFIG)) return true; + if (JSON.stringify(persistentSkillStorageIds) !== JSON.stringify(preset.persistentSkillStorageIds ?? [])) return true; + if (persistentSkillsEnabled !== Boolean(preset.persistentSkillStorage?.enabled)) return true; return false; }, [ name, @@ -326,6 +334,8 @@ export const AgentPresetEditorPanel: FunctionComponent<{ avatarConfig, mcpAccess, memoryConfig, + persistentSkillStorageIds, + persistentSkillsEnabled, preset, knowledgeDirty, ]); @@ -375,6 +385,8 @@ export const AgentPresetEditorPanel: FunctionComponent<{ avatarConfig, mcpAccess, memoryConfig, + persistentSkillStorageIds, + persistentSkillStorage: { enabled: persistentSkillsEnabled && persistentSkillStorageIds.length > 0 }, }); setKnowledgeDirty(false); }; @@ -436,6 +448,7 @@ export const AgentPresetEditorPanel: FunctionComponent<{ const visibleMcpItems = mcpItems.slice(0, 5); const hiddenMcpCount = mcpItems.length - visibleMcpItems.length; const activeMcpCount = mcpItems.filter((item) => item.active).length; + const persistentSkillsActive = persistentSkillsEnabled && persistentSkillStorageIds.length > 0; const toggleMcpItem = (item: (typeof mcpItems)[number]): void => { setActionStatus({ @@ -824,6 +837,70 @@ export const AgentPresetEditorPanel: FunctionComponent<{
+ +
+
+
+
+ Persistent skill retrieval +
+

+ Attach durable skill storages to this agent. Retrieval is disabled until storage is attached and this opt-in is enabled. +

+
+
+ + {persistentSkillsActive ? "Enabled" : "Default off"} + +
+ + {/* Knowledge subscriptions */}

diff --git a/dashboard/src/v2/components/settings/SettingsCategoryRail.tsx b/dashboard/src/v2/components/settings/SettingsCategoryRail.tsx index 5f87e266fb..83f7d63823 100644 --- a/dashboard/src/v2/components/settings/SettingsCategoryRail.tsx +++ b/dashboard/src/v2/components/settings/SettingsCategoryRail.tsx @@ -17,7 +17,7 @@ export const CATEGORIES: Category[] = [ { id: "models", num: "03", label: "AI Models", icon: Cpu, description: "Provider routing, models, and weighting" }, { id: "sprint", num: "04", label: "Sprint & Git", icon: Target, description: "Git flow, branch naming, merge rules, and execution runtime" }, { id: "browser", num: "05", label: "Browser Preview", icon: Compass, description: "Preview runtime, browser visibility, and container policy" }, - { id: "agents", num: "06", label: "Agents", icon: Bot, description: "Project-local markdown mirrors and agent authoring behavior" }, + { id: "agents", num: "06", label: "Agents", icon: Bot, description: "Agent routing, skill storage, reflection, and authoring behavior" }, { id: "memory", num: "07", label: "Memory", icon: BrainCircuit, description: "Embedding models, auto-capture, and promotion policy" }, { id: "integrations", num: "08", label: "Integrations", icon: Plug, description: "Provider keys, Git hosts, and external connection policy" }, { id: "mcp", num: "09", label: "MCP", icon: Server, description: "MCP servers injected into CLIs and built-in tool access" }, diff --git a/dashboard/src/v2/components/settings/panels/QAPanel.tsx b/dashboard/src/v2/components/settings/panels/QAPanel.tsx index d11f47b662..3687e611b9 100644 --- a/dashboard/src/v2/components/settings/panels/QAPanel.tsx +++ b/dashboard/src/v2/components/settings/panels/QAPanel.tsx @@ -2,7 +2,7 @@ import type { ComponentChildren, FunctionComponent } from "preact"; import type { ProjectSettings } from "../../../../types.js"; import { SelectInput, Toggle, NumberInput } from "../SettingsFormFields.js"; import { SectionCard, Row } from "./SharedPanelComponents.js"; -import { ShieldCheck } from "lucide-preact"; +import { Plus, ShieldCheck, Trash2 } from "lucide-preact"; import { DEFAULT_DASHBOARD_SETTINGS } from "../../../../lib/settings.js"; type QaPresetOption = { @@ -35,6 +35,183 @@ const renderOptionIcon = (icon: QaPresetOption["icon"]): ComponentChildren => ( typeof icon === "function" ? icon() : icon ); +type ReflectionLoopSettings = ProjectSettings["agents"]["selfReflection"]["planning"]; +type ReflectionCriterion = ReflectionLoopSettings["criteria"][number]; + +const clampThreshold = (value: number): number => ( + Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0 +); + +const makeCriterionId = (): string => `criterion_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; + +export const SelfReflectionControls: FunctionComponent<{ + title: string; + description: string; + settings: ReflectionLoopSettings; + update: (settings: ReflectionLoopSettings) => void; + getBadge: (path: string) => string | undefined; + basePath: string; + last?: boolean; +}> = ({ title, description, settings, update, getBadge, basePath, last }) => { + const updateCriterion = (criterionId: string, patch: Partial): void => { + update({ + ...settings, + criteria: settings.criteria.map((criterion) => ( + criterion.id === criterionId ? { ...criterion, ...patch } : criterion + )), + }); + }; + + const removeCriterion = (criterionId: string): void => { + update({ + ...settings, + criteria: settings.criteria.filter((criterion) => criterion.id !== criterionId), + }); + }; + + const addCriterion = (): void => { + update({ + ...settings, + criteria: [ + ...settings.criteria, + { + id: makeCriterionId(), + label: "New criterion", + prompt: "", + threshold: 0.8, + }, + ], + }); + }; + + return ( + +

+
+ update({ ...settings, enabled: value })} + /> + + {settings.enabled ? "Opted in" : "Off by default"} + +
+ + + +
+
+
+ Criteria rows +
+ +
+ + {settings.criteria.length === 0 ? ( +
+ No rating criteria are configured. Add a row before enabling self-reflection. +
+ ) : ( +
+ {settings.criteria.map((criterion, index) => { + const labelId = `${basePath}-${criterion.id}-label`; + const promptId = `${basePath}-${criterion.id}-prompt`; + const thresholdId = `${basePath}-${criterion.id}-threshold`; + return ( +
+ +