Skip to content

Commit 515adf8

Browse files
committed
feat(skills): add skill docs and upgrade metadata
Extend skill candidate create/read flows with optional summary, usage notes, and structured pre/post conditions. Add release promotion metadata fields for upgrade lineage and change context, including parent release ID, upgrade reason, and change summary. Propagate these fields through Bay API models/services, SDK types and client methods, MCP handlers/tool schemas, and integration/unit tests.
1 parent 7f2ab0c commit 515adf8

File tree

10 files changed

+222
-9
lines changed

10 files changed

+222
-9
lines changed

pkgs/bay/app/api/v1/skills.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import json
56
from datetime import datetime
67
from typing import Any
78

@@ -22,6 +23,10 @@ class SkillCandidateCreateRequest(BaseModel):
2223
source_execution_ids: list[str]
2324
scenario_key: str | None = None
2425
payload_ref: str | None = None
26+
summary: str | None = None
27+
usage_notes: str | None = None
28+
preconditions: dict[str, Any] | None = None
29+
postconditions: dict[str, Any] | None = None
2530

2631

2732
class SkillCandidateResponse(BaseModel):
@@ -34,6 +39,10 @@ class SkillCandidateResponse(BaseModel):
3439
skill_type: str
3540
auto_release_eligible: bool
3641
auto_release_reason: str | None
42+
summary: str | None
43+
usage_notes: str | None
44+
preconditions: dict[str, Any] | None
45+
postconditions: dict[str, Any] | None
3746
source_execution_ids: list[str]
3847
status: str
3948
latest_score: float | None
@@ -78,6 +87,9 @@ class SkillPromotionRequest(BaseModel):
7887
"""Promotion request."""
7988

8089
stage: str = SkillReleaseStage.CANARY.value
90+
upgrade_of_release_id: str | None = None
91+
upgrade_reason: str | None = None
92+
change_summary: str | None = None
8193

8294

8395
class SkillReleaseResponse(BaseModel):
@@ -95,6 +107,9 @@ class SkillReleaseResponse(BaseModel):
95107
rollback_of: str | None
96108
auto_promoted_from: str | None
97109
health_window_end_at: datetime | None
110+
upgrade_of_release_id: str | None
111+
upgrade_reason: str | None
112+
change_summary: str | None
98113

99114

100115
class SkillReleaseHealthResponse(BaseModel):
@@ -150,6 +165,16 @@ class SkillPayloadResponse(BaseModel):
150165
payload: dict[str, Any] | list[Any]
151166

152167

168+
def _json_field_to_obj(raw: str | None) -> dict[str, Any] | None:
169+
if raw is None:
170+
return None
171+
try:
172+
parsed = json.loads(raw)
173+
except Exception:
174+
return None
175+
return parsed if isinstance(parsed, dict) else None
176+
177+
153178
def _candidate_to_response(candidate) -> SkillCandidateResponse:
154179
source_execution_ids = [item for item in candidate.source_execution_ids.split(",") if item]
155180
return SkillCandidateResponse(
@@ -160,6 +185,10 @@ def _candidate_to_response(candidate) -> SkillCandidateResponse:
160185
skill_type=candidate.skill_type.value,
161186
auto_release_eligible=candidate.auto_release_eligible,
162187
auto_release_reason=candidate.auto_release_reason,
188+
summary=candidate.summary,
189+
usage_notes=candidate.usage_notes,
190+
preconditions=_json_field_to_obj(candidate.preconditions_json),
191+
postconditions=_json_field_to_obj(candidate.postconditions_json),
163192
source_execution_ids=source_execution_ids,
164193
status=candidate.status.value,
165194
latest_score=candidate.latest_score,
@@ -199,6 +228,9 @@ def _release_to_response(release) -> SkillReleaseResponse:
199228
rollback_of=release.rollback_of,
200229
auto_promoted_from=release.auto_promoted_from,
201230
health_window_end_at=release.health_window_end_at,
231+
upgrade_of_release_id=release.upgrade_of_release_id,
232+
upgrade_reason=release.upgrade_reason,
233+
change_summary=release.change_summary,
202234
)
203235

204236

@@ -248,6 +280,10 @@ async def create_candidate(
248280
source_execution_ids=request.source_execution_ids,
249281
scenario_key=request.scenario_key,
250282
payload_ref=request.payload_ref,
283+
summary=request.summary,
284+
usage_notes=request.usage_notes,
285+
preconditions=request.preconditions,
286+
postconditions=request.postconditions,
251287
created_by=owner,
252288
)
253289
return _candidate_to_response(candidate)
@@ -327,6 +363,9 @@ async def promote_candidate(
327363
candidate_id=candidate_id,
328364
stage=stage,
329365
promoted_by=owner,
366+
upgrade_of_release_id=request.upgrade_of_release_id,
367+
upgrade_reason=request.upgrade_reason,
368+
change_summary=request.change_summary,
330369
)
331370
return _release_to_response(release)
332371

pkgs/bay/app/models/skill.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,12 @@ class SkillCandidate(SQLModel, table=True):
127127
auto_release_eligible: bool = Field(default=False, index=True)
128128
auto_release_reason: str | None = Field(default=None)
129129

130+
# Human-readable skill documentation fields.
131+
summary: str | None = Field(default=None)
132+
usage_notes: str | None = Field(default=None)
133+
preconditions_json: str | None = Field(default=None)
134+
postconditions_json: str | None = Field(default=None)
135+
130136
# Comma-separated execution IDs used as source evidence.
131137
source_execution_ids: str = Field(default="")
132138

@@ -184,3 +190,8 @@ class SkillRelease(SQLModel, table=True):
184190
rollback_of: str | None = Field(default=None, index=True)
185191
auto_promoted_from: str | None = Field(default=None, index=True)
186192
health_window_end_at: datetime | None = Field(default=None, index=True)
193+
194+
# Human-readable release-upgrade metadata.
195+
upgrade_of_release_id: str | None = Field(default=None, index=True)
196+
upgrade_reason: str | None = Field(default=None)
197+
change_summary: str | None = Field(default=None)

pkgs/bay/app/services/skills/service.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,10 @@ async def create_candidate(
419419
source_execution_ids: list[str],
420420
scenario_key: str | None = None,
421421
payload_ref: str | None = None,
422+
summary: str | None = None,
423+
usage_notes: str | None = None,
424+
preconditions: dict[str, Any] | None = None,
425+
postconditions: dict[str, Any] | None = None,
422426
created_by: str | None = None,
423427
skill_type: SkillType = SkillType.CODE,
424428
auto_release_eligible: bool = False,
@@ -442,6 +446,16 @@ async def create_candidate(
442446
skill_type=skill_type,
443447
auto_release_eligible=auto_release_eligible,
444448
auto_release_reason=auto_release_reason,
449+
summary=summary,
450+
usage_notes=usage_notes,
451+
preconditions_json=(
452+
json.dumps(preconditions, ensure_ascii=False) if preconditions is not None else None
453+
),
454+
postconditions_json=(
455+
json.dumps(postconditions, ensure_ascii=False)
456+
if postconditions is not None
457+
else None
458+
),
445459
status=SkillCandidateStatus.DRAFT,
446460
created_by=created_by,
447461
created_at=utcnow(),
@@ -587,6 +601,9 @@ async def promote_candidate(
587601
release_mode: SkillReleaseMode = SkillReleaseMode.MANUAL,
588602
auto_promoted_from: str | None = None,
589603
health_window_end_at: datetime | None = None,
604+
upgrade_of_release_id: str | None = None,
605+
upgrade_reason: str | None = None,
606+
change_summary: str | None = None,
590607
) -> SkillRelease:
591608
candidate = await self.get_candidate(owner=owner, candidate_id=candidate_id)
592609

@@ -628,6 +645,9 @@ async def promote_candidate(
628645
promoted_at=utcnow(),
629646
auto_promoted_from=auto_promoted_from,
630647
health_window_end_at=health_window_end_at,
648+
upgrade_of_release_id=upgrade_of_release_id,
649+
upgrade_reason=upgrade_reason,
650+
change_summary=change_summary,
631651
)
632652
self._db.add(release)
633653

pkgs/bay/tests/integration/core/test_skill_lifecycle_api.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,20 @@ async def test_candidate_evaluate_promote_and_rollback_flow():
4242
"source_execution_ids": [exec_a],
4343
"scenario_key": "etl.csv",
4444
"payload_ref": "s3://skills/csv-loader/a",
45+
"summary": "Load CSV into warehouse",
46+
"usage_notes": "Requires warehouse credentials",
47+
"preconditions": {"runtime": "python"},
48+
"postconditions": {"table": "created"},
4549
},
4650
)
4751
assert create_a.status_code == 201
4852
candidate_a = create_a.json()
4953
assert candidate_a["status"] == "draft"
5054
assert candidate_a["source_execution_ids"] == [exec_a]
55+
assert candidate_a["summary"] == "Load CSV into warehouse"
56+
assert candidate_a["usage_notes"] == "Requires warehouse credentials"
57+
assert candidate_a["preconditions"] == {"runtime": "python"}
58+
assert candidate_a["postconditions"] == {"table": "created"}
5159

5260
evaluate_a = await client.post(
5361
f"/v1/skills/candidates/{candidate_a['id']}/evaluate",
@@ -63,14 +71,21 @@ async def test_candidate_evaluate_promote_and_rollback_flow():
6371

6472
promote_a = await client.post(
6573
f"/v1/skills/candidates/{candidate_a['id']}/promote",
66-
json={"stage": "stable"},
74+
json={
75+
"stage": "stable",
76+
"upgrade_reason": "manual_promote",
77+
"change_summary": "Baseline stable release",
78+
},
6779
)
6880
assert promote_a.status_code == 200
6981
release_a = promote_a.json()
7082
assert release_a["skill_key"] == "csv-loader"
7183
assert release_a["version"] == 1
7284
assert release_a["stage"] == "stable"
7385
assert release_a["is_active"] is True
86+
assert release_a["upgrade_reason"] == "manual_promote"
87+
assert release_a["change_summary"] == "Baseline stable release"
88+
assert release_a["upgrade_of_release_id"] is None
7489

7590
exec_b = await _create_python_execution(client, sandbox_id, "print('candidate-b')")
7691
create_b = await client.post(
@@ -91,12 +106,19 @@ async def test_candidate_evaluate_promote_and_rollback_flow():
91106
assert evaluate_b.status_code == 200
92107
promote_b = await client.post(
93108
f"/v1/skills/candidates/{candidate_b['id']}/promote",
94-
json={"stage": "canary"},
109+
json={
110+
"stage": "canary",
111+
"upgrade_of_release_id": release_a["id"],
112+
"upgrade_reason": "metric_improved",
113+
"change_summary": "Improved parsing accuracy",
114+
},
95115
)
96116
assert promote_b.status_code == 200
97117
release_b = promote_b.json()
98118
assert release_b["version"] == 2
99119
assert release_b["is_active"] is True
120+
assert release_b["upgrade_of_release_id"] == release_a["id"]
121+
assert release_b["upgrade_reason"] == "metric_improved"
100122

101123
list_active = await client.get(
102124
"/v1/skills/releases",

shipyard-neo-mcp/src/shipyard_neo_mcp/handlers/skills.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,18 @@ async def handle_create_skill_candidate(
7575
source_execution_ids=source_execution_ids,
7676
scenario_key=optional_str(arguments, "scenario_key"),
7777
payload_ref=optional_str(arguments, "payload_ref"),
78+
summary=optional_str(arguments, "summary"),
79+
usage_notes=optional_str(arguments, "usage_notes"),
80+
preconditions=(
81+
arguments.get("preconditions")
82+
if isinstance(arguments.get("preconditions"), dict)
83+
else None
84+
),
85+
postconditions=(
86+
arguments.get("postconditions")
87+
if isinstance(arguments.get("postconditions"), dict)
88+
else None
89+
),
7890
)
7991
return [
8092
TextContent(
@@ -129,6 +141,9 @@ async def handle_promote_skill_candidate(
129141
release = await client.skills.promote_candidate(
130142
candidate_id,
131143
stage=read_release_stage(arguments, key="stage", default="canary"),
144+
upgrade_of_release_id=optional_str(arguments, "upgrade_of_release_id"),
145+
upgrade_reason=optional_str(arguments, "upgrade_reason"),
146+
change_summary=optional_str(arguments, "change_summary"),
132147
)
133148
return [
134149
TextContent(
@@ -139,7 +154,9 @@ async def handle_promote_skill_candidate(
139154
f"skill_key: {release.skill_key}\n"
140155
f"version: {release.version}\n"
141156
f"stage: {release.stage.value}\n"
142-
f"active: {release.is_active}"
157+
f"active: {release.is_active}\n"
158+
f"upgrade_of_release_id: {getattr(release, 'upgrade_of_release_id', None)}\n"
159+
f"upgrade_reason: {getattr(release, 'upgrade_reason', None)}"
143160
),
144161
)
145162
]

shipyard-neo-mcp/src/shipyard_neo_mcp/tool_defs.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ def get_tool_definitions() -> list[Tool]:
311311
),
312312
Tool(
313313
name="create_skill_candidate",
314-
description="Create a reusable skill candidate from execution IDs.",
314+
description="Create a reusable skill candidate from execution IDs, with optional human-readable summary/notes and pre/post conditions.",
315315
inputSchema={
316316
"type": "object",
317317
"properties": {
@@ -329,6 +329,22 @@ def get_tool_definitions() -> list[Tool]:
329329
"type": "string",
330330
"description": "Optional payload reference.",
331331
},
332+
"summary": {
333+
"type": "string",
334+
"description": "Optional human-readable skill summary.",
335+
},
336+
"usage_notes": {
337+
"type": "string",
338+
"description": "Optional usage notes for operators/agents.",
339+
},
340+
"preconditions": {
341+
"type": "object",
342+
"description": "Optional JSON object describing preconditions.",
343+
},
344+
"postconditions": {
345+
"type": "object",
346+
"description": "Optional JSON object describing postconditions.",
347+
},
332348
},
333349
"required": ["skill_key", "source_execution_ids"],
334350
},
@@ -365,7 +381,7 @@ def get_tool_definitions() -> list[Tool]:
365381
),
366382
Tool(
367383
name="promote_skill_candidate",
368-
description="Promote a passing skill candidate to release.",
384+
description="Promote a passing skill candidate to release, with optional upgrade metadata (parent release, reason, and change summary).",
369385
inputSchema={
370386
"type": "object",
371387
"properties": {
@@ -377,6 +393,18 @@ def get_tool_definitions() -> list[Tool]:
377393
"type": "string",
378394
"description": "Release stage: canary or stable. Defaults to canary.",
379395
},
396+
"upgrade_of_release_id": {
397+
"type": "string",
398+
"description": "Optional parent release ID this promotion upgrades from.",
399+
},
400+
"upgrade_reason": {
401+
"type": "string",
402+
"description": "Optional reason for this promotion/upgrade.",
403+
},
404+
"change_summary": {
405+
"type": "string",
406+
"description": "Optional human-readable change summary.",
407+
},
380408
},
381409
"required": ["candidate_id"],
382410
},

0 commit comments

Comments
 (0)