feat(dify-builder): real governance tail — policy branch + build.await_learning (C-2)

Rework handle_governance_feedback into the 3-policy branch (automatic/
disabled/ask) reading fc.skill_learning_policy, and add handle_await_learning
to resolve the ask-policy accept_learning/skip_learning turn. Both funnel
into a shared _emit_completion helper for the build-complete summary.
Registered BUILD_AWAIT_LEARNING in build_registry().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Charles YaoandClaude Opus 4.8 committed 2026-08-23 12:46:39 -07:00
1 parent 893ffedbf0
commit bad0046ccf
2 files changed
+155 -21

No files matched your search

+59 -11
View File
@@ -12,6 +12,7 @@ import uuid
from core.dify_builder.contract import (
AssistantTurnItem,
BuildLearningCard,
ChallengeCard,
ChangeSetCard,
CheckpointCard,
@@ -20,6 +21,7 @@ from core.dify_builder.contract import (
ErrorCard,
FormCard,
FormField,
NoticeItem,
PlanCard,
PublishCard,
ResourceSelectCard,
@@ -31,12 +33,21 @@ from core.dify_builder.contract import (
TraceStep,
)
from core.dify_builder.handlers_fix import action_string, append_card, perform_revert
from core.dify_builder.models import ChangeSet, Checkpoint, DifyBuilderContext, Session, Snapshot, Turn
from core.dify_builder.models import (
ChangeSet,
Checkpoint,
ConversationItem,
DifyBuilderContext,
Session,
Snapshot,
Turn,
)
from core.dify_builder.runner import Env, Handler, StepResult
from core.dify_builder.state import PcState
__all__ = [
"build_registry",
"handle_await_learning",
"handle_capability_check",
"handle_execution",
"handle_goal_analysis",
@@ -58,6 +69,16 @@ def _emit_canvas(env: Env, event: str, **extra) -> None:
env.emit_canvas({"event": event, **extra})
def _emit_completion(fc: DifyBuilderContext) -> list[ConversationItem]:
"""Shared build-complete summary, emitted on every governance-tail exit."""
rows = [
SummaryRow(label="Workflow", value="Start -> Knowledge -> LLM -> End"),
SummaryRow(label="Nodes", value=str(len(fc.built_node_ids))),
SummaryRow(label="Status", value="Complete"),
]
return append_card(fc, SummaryCard(variant="completion", title="Build complete", rows=rows))
_REQUIREMENT_FIELDS = [
FormField(key="report_types", label="Report types", type="text"),
FormField(key="audience", label="Audience", type="text"),
@@ -481,16 +502,42 @@ def handle_publish(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) ->
def handle_governance_feedback(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
"""(working, auto) Governance tail is spec-deferred (no body). As the
handler transitioning INTO the terminal build.complete, it emits the
completion summary. Reached both via publish and via keep_draft (which
skips publish), so the status row is scenario-neutral."""
rows = [
SummaryRow(label="Workflow", value="Start -> Knowledge -> LLM -> End"),
SummaryRow(label="Nodes", value=str(len(fc.built_node_ids))),
SummaryRow(label="Status", value="Complete"),
]
items = append_card(fc, SummaryCard(variant="completion", title="Build complete", rows=rows))
"""(working, auto) Governance tail: apply the skill-learning policy.
automatic -> learn + accepted card; disabled -> skipped card; ask ->
emit the pending prompt and rest at build.await_learning. Reached via both
publish and keep_draft (scenario-neutral)."""
policy = fc.skill_learning_policy or "ask"
if policy == "automatic":
descriptor = env.agent.learn_from_build(
fc.goal_text, dict(fc.requirements), list(fc.plan_items), list(fc.built_node_ids)
)
items = append_card(fc, BuildLearningCard(policy="automatic", state="accepted"))
items += append_card(fc, NoticeItem(text=descriptor))
items += _emit_completion(fc)
return StepResult(next=PcState.BUILD_COMPLETE, context=fc, items=items)
if policy == "disabled":
items = append_card(fc, BuildLearningCard(policy="disabled", state="skipped"))
items += _emit_completion(fc)
return StepResult(next=PcState.BUILD_COMPLETE, context=fc, items=items)
# ask (default): prompt, then rest for the user's accept/skip.
items = append_card(fc, BuildLearningCard(policy="ask", state="pending"))
return StepResult(next=PcState.BUILD_AWAIT_LEARNING, context=fc, items=items)
def handle_await_learning(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
"""(waiting) Resolve the ask-policy skill-learning prompt. accept_learning
-> learn + accepted decision; anything else (skip_learning / absent) ->
skipped. Either way emit the completion summary and reach build.complete."""
kind = turn.action.kind if turn.action is not None else ""
if kind == "accept_learning":
descriptor = env.agent.learn_from_build(
fc.goal_text, dict(fc.requirements), list(fc.plan_items), list(fc.built_node_ids)
)
items = append_card(fc, DecisionItem(text="Accepted skill learning"))
items += append_card(fc, NoticeItem(text=descriptor))
else:
items = append_card(fc, DecisionItem(text="Skipped skill learning"))
items += _emit_completion(fc)
return StepResult(next=PcState.BUILD_COMPLETE, context=fc, items=items)
@@ -530,5 +577,6 @@ def build_registry() -> dict[PcState, Handler]:
PcState.BUILD_REVIEW: handle_review,
PcState.BUILD_PUBLISH: handle_publish,
PcState.BUILD_GOVERNANCE_FEEDBACK: handle_governance_feedback,
PcState.BUILD_AWAIT_LEARNING: handle_await_learning,
PcState.BUILD_REVERTED: handle_reverted,
}
@@ -21,11 +21,11 @@ def _actor() -> Actor:
return Actor(account_id="acc-1", tenant_id="tenant-1")
def _new_env(dify=None, emit_canvas=None) -> tuple[Env, InMemoryRepository]:
def _new_env(dify=None, emit_canvas=None, agent=None) -> tuple[Env, InMemoryRepository]:
repo = InMemoryRepository()
env = Env(
dify=dify or FakeDifyPort(),
agent=PlaceholderAgent(),
agent=agent or PlaceholderAgent(),
repo=repo,
now=lambda: datetime.min,
emit_canvas=emit_canvas,
@@ -329,20 +329,97 @@ def test_publish_calls_dify_and_advances_to_governance_feedback():
assert {"event": "publish_workflow"} in events
def test_governance_feedback_emits_completion_summary_and_reaches_complete():
def test_governance_automatic_learns_and_reaches_complete():
from core.dify_builder.handlers_build import handle_governance_feedback
from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort, StubAgent
env, repo = _new_env()
env, repo = _new_env(dify=FakeBuildDifyPort(), agent=StubAgent())
s = _seed_build_session(
repo,
PcState.BUILD_GOVERNANCE_FEEDBACK,
built_node_ids=["start", "knowledge_retrieval", "llm", "end"],
skill_learning_policy="automatic",
built_node_ids=["a", "b"],
)
res = handle_governance_feedback(env, Turn(actor=_actor()), *repo.get_session(s.id))
assert res.next == PcState.BUILD_COMPLETE
summary = next(i for i in res.items if i.kind == "summary")
assert summary.payload["variant"] == "completion"
assert summary.payload["rows"]
kinds = [i.kind for i in res.items]
assert "build_learning" in kinds and "summary" in kinds
assert env.agent.learn_calls == 1 # seam called for automatic
def test_governance_disabled_skips_and_reaches_complete():
from core.dify_builder.handlers_build import handle_governance_feedback
from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort, StubAgent
env, repo = _new_env(dify=FakeBuildDifyPort(), agent=StubAgent())
s = _seed_build_session(
repo,
PcState.BUILD_GOVERNANCE_FEEDBACK,
skill_learning_policy="disabled",
built_node_ids=["a"],
)
res = handle_governance_feedback(env, Turn(actor=_actor()), *repo.get_session(s.id))
assert res.next == PcState.BUILD_COMPLETE
# build_learning present with state skipped; seam NOT called
bl = [i for i in res.items if i.kind == "build_learning"][0]
assert bl.payload["state"] == "skipped"
assert getattr(env.agent, "learn_calls", 0) == 0
def test_governance_ask_rests_at_await_learning_with_pending_card():
from core.dify_builder.handlers_build import handle_governance_feedback
from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort, StubAgent
env, repo = _new_env(dify=FakeBuildDifyPort(), agent=StubAgent())
s = _seed_build_session(
repo,
PcState.BUILD_GOVERNANCE_FEEDBACK,
skill_learning_policy="ask",
built_node_ids=["a"],
)
res = handle_governance_feedback(env, Turn(actor=_actor()), *repo.get_session(s.id))
assert res.next == PcState.BUILD_AWAIT_LEARNING
bl = [i for i in res.items if i.kind == "build_learning"][0]
assert bl.payload["policy"] == "ask" and bl.payload["state"] == "pending"
assert getattr(env.agent, "learn_calls", 0) == 0 # not learned until accepted
def test_await_learning_accept_learns_and_completes():
from core.dify_builder.handlers_build import handle_await_learning
from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort, StubAgent
env, repo = _new_env(dify=FakeBuildDifyPort(), agent=StubAgent())
s = _seed_build_session(
repo,
PcState.BUILD_AWAIT_LEARNING,
skill_learning_policy="ask",
built_node_ids=["a"],
)
res = handle_await_learning(
env, Turn(action=Action(kind="accept_learning", base_version=1), actor=_actor()), *repo.get_session(s.id)
)
assert res.next == PcState.BUILD_COMPLETE
kinds = [i.kind for i in res.items]
assert "decision" in kinds and "summary" in kinds
assert env.agent.learn_calls == 1
def test_await_learning_skip_completes_without_learning():
from core.dify_builder.handlers_build import handle_await_learning
from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort, StubAgent
env, repo = _new_env(dify=FakeBuildDifyPort(), agent=StubAgent())
s = _seed_build_session(
repo,
PcState.BUILD_AWAIT_LEARNING,
skill_learning_policy="ask",
built_node_ids=["a"],
)
res = handle_await_learning(
env, Turn(action=Action(kind="skip_learning", base_version=1), actor=_actor()), *repo.get_session(s.id)
)
assert res.next == PcState.BUILD_COMPLETE
assert getattr(env.agent, "learn_calls", 0) == 0
def test_reverted_retry_returns_to_initial_plan():
@@ -372,6 +449,7 @@ def test_build_registry_covers_all_non_terminal_build_states():
PcState.BUILD_REVIEW,
PcState.BUILD_PUBLISH,
PcState.BUILD_GOVERNANCE_FEEDBACK,
PcState.BUILD_AWAIT_LEARNING,
PcState.BUILD_REVERTED,
}
assert PcState.BUILD_COMPLETE not in build_registry() # terminal: no handler
@@ -418,12 +496,17 @@ def test_full_build_flow_goal_to_complete():
out = runner.advance(s.id, Turn(action=Action(kind="run_test", base_version=out.version), actor=_actor()))
assert out.current_state == PcState.BUILD_REVIEW
# 7) publish_workflow -> build.publish (auto) -> governance_feedback (auto) -> build.complete
# 7) publish_workflow -> build.publish (auto) -> governance_feedback (auto)
# -> rests at build.await_learning (default policy "ask")
publish_action = Action(kind="publish_workflow", base_version=out.version)
out = runner.advance(s.id, Turn(action=publish_action, actor=_actor()))
assert out.current_state == PcState.BUILD_COMPLETE
assert out.current_state == PcState.BUILD_AWAIT_LEARNING
assert dify.published is True
# 8) skip_learning -> build.complete
out = runner.advance(s.id, Turn(action=Action(kind="skip_learning", base_version=out.version), actor=_actor()))
assert out.current_state == PcState.BUILD_COMPLETE
# ordered card stream: every Build card kind appears, seq-ordered.
items = repo.list_conversation(s.id)
kinds = [i.kind for i in items]
@@ -470,6 +553,9 @@ def test_full_build_flow_keep_draft_reaches_complete_without_publish():
assert out.current_state == PcState.BUILD_REVIEW
out = runner.advance(s.id, Turn(action=Action(kind="keep_draft", base_version=out.version), actor=_actor()))
assert out.current_state == PcState.BUILD_AWAIT_LEARNING # default policy "ask"
out = runner.advance(s.id, Turn(action=Action(kind="skip_learning", base_version=out.version), actor=_actor()))
assert out.current_state == PcState.BUILD_COMPLETE
assert dify.published is False # keep_draft skips publish
assert not any(i.kind == "publish" for i in repo.list_conversation(s.id))