Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/bernstein/core/agents/spawn_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,58 @@ def _render_completion_instructions(tasks: list[Task]) -> str:
return f"{header}{block}\nThen exit."


def render_artifact_contract(tasks: list[Task]) -> str:
"""Render the artifact contract section for artifact-mode tasks (#4539).

An artifact-mode task completes on a signed lineage receipt over its
produced artifact, not on a git SHA - but until now the spawn prompt never
surfaced the contract the completion side (:mod:`bernstein.core.tasks.artifact_completion`)
actually enforces. This helper closes that gap: the agent is shown the
exact kind, the exact output path the verifier reads, and every declared
acceptance criterion.

Reads the *same* ``task.artifact_spec`` object and the *same*
:func:`~bernstein.core.tasks.artifact_completion.artifact_output_path`
resolver as the completion path, so the prompt and the verifier cannot
drift - there is no second parse and no parallel rendering of the spec.

Returns ``""`` when every task completes through the git path
(``code_diff``), so the default coding-task prompt stays byte-unchanged.
"""
from bernstein.core.tasks.artifact_completion import artifact_output_path, is_artifact_mode

artifact_tasks = [t for t in tasks if is_artifact_mode(t)]
if not artifact_tasks:
return ""

lines: list[str] = [
"## Artifact contract",
"",
"Your work is judged by the artifacts declared below, not by a source "
"diff. Produce exactly the declared artifact; when it is complete the "
"orchestrator records a signed lineage receipt over its canonical bytes.",
]
for t in artifact_tasks:
spec = t.artifact_spec
out_path = artifact_output_path(t)
lines.extend(("", f"### {t.id}: {t.title}"))
lines.append(f"- **Kind**: `{spec.kind.value}`")
lines.append(f"- **Output path**: `{out_path}` (relative to the working directory)")
criteria = list(spec.criteria)
if criteria:
lines.append("- **Acceptance criteria** (every one must hold):")
for criterion in criteria:
lines.append(f" - `{criterion.type}`: {criterion.value}")
else:
lines.append("- **Acceptance criteria**: none declared")
lines.append(
f"- Completion is judged by this contract. Write the artifact to `{out_path}` "
f"and leave it there; if you cannot satisfy a criterion, do not mark the "
f"task complete - report a typed refusal instead."
)
return "\n".join(lines) + "\n"


def _render_prompt(
tasks: list[Task],
templates_dir: Path,
Expand Down Expand Up @@ -900,6 +952,12 @@ def _render_prompt(
if memory_block:
named_sections.append(("memory_lessons", memory_block))
named_sections.append(("tasks", f"\n## Assigned tasks\n{task_block}"))
# Artifact contract (#4539): surface the kind/path/criteria an
# artifact-mode task is judged by. Empty for the git path, so a plain
# coding task's prompt is unchanged.
artifact_contract = render_artifact_contract(tasks)
if artifact_contract:
named_sections.append(("artifact contract", f"\n{artifact_contract}"))
if lesson_context:
named_sections.append(("lessons", f"\n{lesson_context}\n"))
if rich_context:
Expand Down
8 changes: 8 additions & 0 deletions src/bernstein/core/agents/spawner_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1250,6 +1250,14 @@ def _render_prompt_with_receipt(
if specialist_block:
named_sections.append(("specialists", specialist_block))
named_sections.append(("tasks", f"\n## Assigned tasks\n{task_block}"))
# Artifact contract (#4539): surface the kind/path/criteria an
# artifact-mode task is judged by. Empty for the git path, so a plain
# coding task's prompt is unchanged.
from bernstein.core.agents.spawn_prompt import render_artifact_contract

artifact_contract = render_artifact_contract(tasks)
if artifact_contract:
named_sections.append(("artifact_contract", f"\n{artifact_contract}"))
if lesson_context:
named_sections.append(("lessons", f"\n{lesson_context}\n"))
if persistent_memory_context:
Expand Down
102 changes: 102 additions & 0 deletions tests/unit/agents/test_artifact_contract_prompt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Issue #4539 - artifact contract surfaced in the spawn prompt.

An artifact-mode task completes on a signed lineage receipt over its produced
artifact, not on a git SHA. But until now the spawn prompt never surfaced the
contract the completion path actually enforces, so an agent could only learn
"write a dataset to reports/out.jsonl with these criteria" from the operator
hand-duplicating the contract into free-text. When they forgot, completion
failed on criteria the agent never saw.

These tests assemble the live agent prompt exactly as production does
(``spawner_core._render_prompt``) and assert that an artifact-mode task's
prompt names the kind, the exact output path the verifier reads, and every
declared criterion - and that a plain ``code_diff`` task's prompt is
byte-unchanged.
"""

from __future__ import annotations

from pathlib import Path

from bernstein.core.models import Task

from bernstein import _BUNDLED_TEMPLATES_DIR
from bernstein.core.agents.spawner_core import _render_prompt
from bernstein.core.tasks.artifact_completion import (
artifact_output_path,
is_artifact_mode,
)
from bernstein.core.tasks.artifacts import (
ArtifactCriterion,
ArtifactKind,
ArtifactSpec,
)


def _render(tasks: list[Task], tmp_path: Path) -> str:
"""Assemble the prompt the way the production spawner does."""
workdir = tmp_path / "workdir"
(workdir / ".sdd").mkdir(parents=True, exist_ok=True)
return _render_prompt(tasks, _BUNDLED_TEMPLATES_DIR / "roles", workdir)


def _artifact_task() -> Task:
spec = ArtifactSpec(
kind=ArtifactKind.DATASET,
output_path="reports/out.jsonl",
criteria=(
ArtifactCriterion(type="schema_valid", value='{"type": "object"}'),
ArtifactCriterion(type="hash_stable", value="sha256"),
),
)
return Task(
id="T-ART-1",
title="Produce the evaluation dataset",
description="Build a fixture dataset for the eval harness.",
role="analyst",
artifact_spec=spec,
)


def _code_diff_task() -> Task:
return Task(
id="T-CODE-1",
title="Add a unit test",
description="Add a test for the new resolver.",
role="backend",
)


def test_artifact_task_prompt_names_kind_path_and_criteria(tmp_path: Path) -> None:
task = _artifact_task()
prompt = _render([task], tmp_path)

assert "Artifact contract" in prompt, "artifact contract section missing"
assert f"`{ArtifactKind.DATASET.value}`" in prompt, "kind not named"
assert "reports/out.jsonl" in prompt, "output path not named"
assert "schema_valid" in prompt, "schema criterion missing"
assert "hash_stable" in prompt, "hash criterion missing"
# The prompt and verifier name the same path - no drift channel.
assert artifact_output_path(task) in prompt, "prompt path differs from verifier path"


def test_code_diff_task_prompt_is_unchanged(tmp_path: Path) -> None:
task = _code_diff_task()
assert not is_artifact_mode(task), "code_diff task must not be artifact mode"
prompt = _render([task], tmp_path)

assert "Artifact contract" not in prompt, "artifact contract leaked into code_diff prompt"
assert task.artifact_spec.kind.value not in prompt # kind value not rendered


def test_prompt_and_verifier_consume_the_same_spec_object(tmp_path: Path) -> None:
task = _artifact_task()
prompt = _render([task], tmp_path)

# The prompt renders from the same declared spec and the same resolver the
# verifier uses - single source of truth, one parse, no drift.
spec = task.artifact_spec
assert spec.kind.value in prompt
assert artifact_output_path(task) in prompt
for criterion in spec.criteria:
assert criterion.type in prompt, f"criterion {criterion.type!r} not rendered"
14 changes: 14 additions & 0 deletions tests/unit/test_lessons.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,8 @@ def test_render_prompt_includes_lessons(self, temp_sdd_dir: Path) -> None:

from bernstein.core.spawner import _render_prompt

from bernstein.core.tasks.artifacts import ArtifactSpec

# File a lesson with a tag that will match
file_lesson(
sdd_dir=temp_sdd_dir,
Expand All @@ -506,6 +508,11 @@ def test_render_prompt_includes_lessons(self, temp_sdd_dir: Path) -> None:
task.mcp_servers = []
task.parent_context = None
task.depends_on = []
# The renderer reads the artifact contract to decide whether the task
# completes on a receipt or on a commit. A bare MagicMock answers that
# question with a Mock, which is not `code_diff`, so the prompt would
# try to render a contract for a task that has none.
task.artifact_spec = ArtifactSpec()

workdir = temp_sdd_dir.parent
templates_dir = workdir / "templates" / "roles"
Expand All @@ -526,6 +533,8 @@ def test_render_prompt_no_lessons_when_no_match(self, temp_sdd_dir: Path) -> Non

from bernstein.core.spawner import _render_prompt

from bernstein.core.tasks.artifacts import ArtifactSpec

# File a lesson with unrelated tags
file_lesson(
sdd_dir=temp_sdd_dir,
Expand All @@ -546,6 +555,11 @@ def test_render_prompt_no_lessons_when_no_match(self, temp_sdd_dir: Path) -> Non
task.mcp_servers = []
task.parent_context = None
task.depends_on = []
# The renderer reads the artifact contract to decide whether the task
# completes on a receipt or on a commit. A bare MagicMock answers that
# question with a Mock, which is not `code_diff`, so the prompt would
# try to render a contract for a task that has none.
task.artifact_spec = ArtifactSpec()

workdir = temp_sdd_dir.parent
templates_dir = workdir / "templates" / "roles"
Expand Down
Loading