Skip to content

Commit 11b2d5c

Browse files
authored
fix(control-plane): require visions for long-lived peer lanes (#2343)
1 parent d6d7bbc commit 11b2d5c

9 files changed

Lines changed: 187 additions & 5 deletions

File tree

examples/control_plane/agent-identity-readmodel-smoke.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
build_quota_agent_identity,
1414
quota_registered_agents,
1515
)
16+
from loopx.control_plane.agents.profile import normalize_agent_profile # noqa: E402
1617
from loopx.control_plane.agents.runtime_model import ( # noqa: E402
1718
AgentRuntimeModel,
1819
agent_runtime_model_for_goal,
@@ -87,6 +88,40 @@ def assert_peer_identity_projects_only_valid_advisory_profile() -> None:
8788
assert "agent_profile" not in invalid_identity, invalid_identity
8889

8990

91+
def assert_long_lived_profile_requires_vision_by_default() -> None:
92+
profile = normalize_agent_profile(
93+
{
94+
"agent_id": AGENTS[1],
95+
"default_task_classes": ["advancement_task", "continuous_monitor"],
96+
},
97+
registered_agents=AGENTS,
98+
)
99+
assert profile["vision_requirement"] == "required", profile
100+
101+
optional = normalize_agent_profile(
102+
{
103+
"agent_id": AGENTS[1],
104+
"default_task_classes": ["advancement_task", "continuous_monitor"],
105+
"vision_requirement": "optional",
106+
},
107+
registered_agents=AGENTS,
108+
)
109+
assert optional["vision_requirement"] == "optional", optional
110+
111+
try:
112+
normalize_agent_profile(
113+
{
114+
"agent_id": AGENTS[1],
115+
"vision_requirement": "sometimes",
116+
},
117+
registered_agents=AGENTS,
118+
)
119+
except ValueError as exc:
120+
assert "vision_requirement" in str(exc), exc
121+
else:
122+
raise AssertionError("invalid vision requirement should fail closed")
123+
124+
90125
def assert_legacy_state_only_projects_migration() -> None:
91126
goal = legacy_goal()
92127
identity = build_quota_agent_identity(goal, agent_id=AGENTS[1])
@@ -136,6 +171,7 @@ def assert_errors_are_actionable() -> None:
136171
def main() -> None:
137172
assert_peer_identity_has_no_rank()
138173
assert_peer_identity_projects_only_valid_advisory_profile()
174+
assert_long_lived_profile_requires_vision_by_default()
139175
assert_legacy_state_only_projects_migration()
140176
assert_assignment_is_deterministic()
141177
assert_errors_are_actionable()

examples/control_plane/quota-replan-decision-plane-smoke.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,6 +1065,58 @@ def assert_repeat_advancement_vision_replans_past_peer_only_work() -> None:
10651065
assert "next runnable" in action["text"], guard
10661066

10671067

1068+
def assert_required_profile_without_vision_replans_past_peer_only_work() -> None:
1069+
payload = status_payload(
1070+
[monitor_item(), primary_claimed_advancement()],
1071+
replan_obligation=None,
1072+
)
1073+
profile = {
1074+
"schema_version": "agent_profile_v1",
1075+
"agent_id": SIDE_AGENT,
1076+
"profile_role": "quality-qualification",
1077+
"scope_summary": "Continuous qualification and maintainability work.",
1078+
"default_task_classes": ["advancement_task", "continuous_monitor"],
1079+
}
1080+
for coordination in (
1081+
payload["attention_queue"]["items"][0]["coordination"],
1082+
payload["run_history"]["goals"][0]["coordination"],
1083+
):
1084+
coordination["agent_profiles"] = {SIDE_AGENT: profile}
1085+
1086+
guard = build_quota_should_run(
1087+
payload,
1088+
goal_id=GOAL_ID,
1089+
agent_id=SIDE_AGENT,
1090+
)
1091+
1092+
assert guard["decision"] == "autonomous_replan_required", guard
1093+
assert guard["effective_action"] == "autonomous_replan_required", guard
1094+
assert guard["agent_identity"]["agent_profile"]["vision_requirement"] == (
1095+
"required"
1096+
), guard
1097+
gaps = guard["goal_frontier_projection"]["acceptance_gaps"]
1098+
assert gaps[0]["kind"] == "required_agent_vision_missing", guard
1099+
assert gaps[0]["source"] == "agent_profile", guard
1100+
frontier = guard["goal_frontier_projection"]["remaining_advancement_frontier"]
1101+
assert frontier == {
1102+
"current_agent_claimed_advancement_count": 0,
1103+
"unclaimed_advancement_count": 0,
1104+
"other_agent_claimed_advancement_count": 1,
1105+
}, guard
1106+
assert "agent_scope_frontier" not in guard, guard
1107+
1108+
profile["vision_requirement"] = "optional"
1109+
optional_guard = build_quota_should_run(
1110+
payload,
1111+
goal_id=GOAL_ID,
1112+
agent_id=SIDE_AGENT,
1113+
)
1114+
assert optional_guard["decision"] != "autonomous_replan_required", optional_guard
1115+
assert optional_guard["goal_frontier_projection"]["acceptance_gaps"] == [], (
1116+
optional_guard
1117+
)
1118+
1119+
10681120
def assert_repeat_advancement_vision_accepts_runnable_successor() -> None:
10691121
guard = build_quota_should_run(
10701122
status_payload(
@@ -1584,6 +1636,7 @@ def main() -> None:
15841636
assert_due_monitor_runs_under_watched_open_agent_vision()
15851637
assert_repeat_advancement_vision_beats_watch_lane_continuation_ack()
15861638
assert_repeat_advancement_vision_replans_past_peer_only_work()
1639+
assert_required_profile_without_vision_replans_past_peer_only_work()
15871640
assert_repeat_advancement_vision_accepts_runnable_successor()
15881641
assert_non_watch_replan_ack_does_not_suppress_open_agent_vision()
15891642
assert_open_agent_vision_with_runnable_frontier_uses_neutral_gap_trigger()

loopx/cli_commands/registry_admin.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,8 @@ def register_registry_admin_commands(subparsers: argparse._SubParsersAction) ->
349349
default=None,
350350
help=(
351351
"Validated agent_profile_v1 JSON object for a registered peer. "
352-
"Repeatable; writes advisory task routing hints only."
352+
"Repeatable; writes advisory task routing hints plus the peer lane's "
353+
"vision requirement."
353354
),
354355
)
355356
configure_goal_parser.add_argument(

loopx/cli_commands/status.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,12 @@ def _build_agent_member_projection(
567567
scope_summary = _profile_scope_summary(profile)
568568
if scope_summary:
569569
member["scope_summary"] = scope_summary
570+
vision_requirement = _compact_member_text(
571+
profile.get("vision_requirement"),
572+
limit=16,
573+
)
574+
if vision_requirement:
575+
member["vision_requirement"] = vision_requirement
570576
member["handoff_assignment_status"] = "task_policy_selected"
571577
return member
572578

loopx/control_plane/agents/profile.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"profile_role",
1919
"scope_summary",
2020
"default_task_classes",
21+
"vision_requirement",
2122
"preferred_action_kinds",
2223
"avoid_action_kinds",
2324
}
@@ -32,6 +33,7 @@
3233
AGENT_PROFILE_HIERARCHY_AGENT_ROLE = re.compile(
3334
r"(?:^|-)(?:main|primary|side)-agent(?:-|$)"
3435
)
36+
AGENT_PROFILE_VISION_REQUIREMENTS = {"optional", "required"}
3537

3638

3739
def _bounded_text(value: Any, *, field: str, limit: int) -> str | None:
@@ -103,6 +105,29 @@ def _action_patterns(value: Any, *, field: str) -> list[str]:
103105
return patterns
104106

105107

108+
def _vision_requirement(value: Any, *, task_classes: list[str]) -> str | None:
109+
requirement = str(value or "").strip().lower()
110+
if requirement and requirement not in AGENT_PROFILE_VISION_REQUIREMENTS:
111+
raise ValueError(
112+
"agent profile vision_requirement must be optional or required"
113+
)
114+
if requirement:
115+
return requirement
116+
if {"advancement_task", "continuous_monitor"}.issubset(task_classes):
117+
return "required"
118+
return None
119+
120+
121+
def agent_profile_requires_vision(profile: Mapping[str, Any] | None) -> bool:
122+
if not isinstance(profile, Mapping):
123+
return False
124+
requirement = str(profile.get("vision_requirement") or "").strip().lower()
125+
if requirement:
126+
return requirement == "required"
127+
task_classes = _task_classes(profile.get("default_task_classes"))
128+
return {"advancement_task", "continuous_monitor"}.issubset(task_classes)
129+
130+
106131
def normalize_agent_profile(
107132
raw_profile: Mapping[str, Any],
108133
*,
@@ -140,6 +165,7 @@ def normalize_agent_profile(
140165
"agent profile action kind globs cannot be both preferred and avoided: "
141166
+ ", ".join(overlap)
142167
)
168+
task_classes = _task_classes(raw_profile.get("default_task_classes"))
143169
profile = {
144170
"schema_version": PEER_AGENT_PROFILE_SCHEMA_VERSION,
145171
"agent_id": agent_id,
@@ -149,8 +175,10 @@ def normalize_agent_profile(
149175
field="scope_summary",
150176
limit=320,
151177
),
152-
"default_task_classes": _task_classes(
153-
raw_profile.get("default_task_classes")
178+
"default_task_classes": task_classes,
179+
"vision_requirement": _vision_requirement(
180+
raw_profile.get("vision_requirement"),
181+
task_classes=task_classes,
154182
),
155183
"preferred_action_kinds": preferred,
156184
"avoid_action_kinds": avoided,

loopx/control_plane/goals/goal_frontier.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
agent_scope_item_claimed_by,
99
agent_scope_item_claimed_by_agent_or_unclaimed,
1010
)
11+
from ..agents.profile import agent_profile_requires_vision
1112
from ..agents.runtime_model import peer_work_key, select_peer_for_work
1213
from ..work_items.autonomous_replan_ack import (
1314
autonomous_replan_ack_matches_frontier,
@@ -45,6 +46,7 @@
4546
VISION_ACCEPTANCE_GAP_TRIGGER = "vision_acceptance_gap"
4647
VISION_SUCCESSOR_GAP_TRIGGER = "vision_successor_required"
4748
VISION_CHECKPOINT_MISSING_TRIGGER = "vision_checkpoint_missing"
49+
VISION_PROFILE_MISSING_TRIGGER = "required_agent_vision_missing"
4850
TODO_SUCCESSION_GAP_TRIGGER = "completed_advancement_without_successor"
4951
TODO_TASK_CLASS_ADVANCEMENT = "advancement_task"
5052
TODO_TASK_CLASS_MONITOR = "continuous_monitor"
@@ -634,6 +636,41 @@ def acceptance_gaps_from_agent_vision(
634636
return [gap]
635637

636638

639+
def acceptance_gaps_from_agent_profile_requirement(
640+
agent_profile: dict[str, Any] | None,
641+
*,
642+
agent_id: str | None,
643+
agent_vision: dict[str, Any] | None,
644+
missing_checkpoint: dict[str, Any] | None,
645+
) -> list[dict[str, Any]]:
646+
"""Require a vision baseline for registered long-lived peer lanes."""
647+
648+
if (
649+
not agent_id
650+
or not agent_profile_requires_vision(agent_profile)
651+
or isinstance(agent_vision, dict)
652+
or isinstance(missing_checkpoint, dict)
653+
):
654+
return []
655+
return [
656+
{
657+
"kind": VISION_PROFILE_MISSING_TRIGGER,
658+
"source": "agent_profile",
659+
"agent_id": agent_id,
660+
"replan_trigger_summary": (
661+
"the registered long-lived agent lane requires a persisted vision "
662+
"baseline before ordinary delivery or monitor-only quiet wait"
663+
),
664+
"acceptance_summary": (
665+
"Write a bounded agent vision with objective, acceptance evidence, "
666+
"advancement policy, and replan trigger; or explicitly mark the "
667+
"profile vision requirement optional."
668+
),
669+
"advancement_policy": "repeat_until_closed",
670+
}
671+
]
672+
673+
637674
def acceptance_gaps_from_vision_checkpoint(
638675
checkpoint: dict[str, Any] | None,
639676
) -> list[dict[str, Any]]:
@@ -1187,7 +1224,8 @@ def derive_goal_frontier_replan_obligation_from_summaries(
11871224
item for item in (acceptance_gaps or []) if isinstance(item, dict)
11881225
]
11891226
successor_vision_required = any(
1190-
item.get("kind") == VISION_SUCCESSOR_GAP_TRIGGER
1227+
item.get("kind")
1228+
in {VISION_SUCCESSOR_GAP_TRIGGER, VISION_PROFILE_MISSING_TRIGGER}
11911229
for item in compact_acceptance_gaps
11921230
)
11931231
acceptance_allows_watch_lane_continuation = bool(
@@ -1514,6 +1552,7 @@ def build_goal_frontier_projection_context_from_status(
15141552
neutral_replan_ack_classifications: set[str],
15151553
registered_agent_ids: list[str] | None = None,
15161554
goal_status: str | None = None,
1555+
agent_profile: dict[str, Any] | None = None,
15171556
) -> dict[str, Any]:
15181557
"""Build the quota-facing goal-frontier read model.
15191558
@@ -1548,7 +1587,13 @@ def build_goal_frontier_projection_context_from_status(
15481587
agent_id=agent_id,
15491588
)
15501589
source_acceptance_gaps = (
1551-
acceptance_gaps_from_agent_vision(
1590+
acceptance_gaps_from_agent_profile_requirement(
1591+
agent_profile,
1592+
agent_id=agent_id,
1593+
agent_vision=latest_agent_vision,
1594+
missing_checkpoint=latest_missing_vision_checkpoint,
1595+
)
1596+
+ acceptance_gaps_from_agent_vision(
15521597
latest_agent_vision,
15531598
goal_status=goal_status,
15541599
)

loopx/heartbeat_prompt.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ def agent_profile_prompt_projection(profile: dict[str, Any] | None) -> dict[str,
136136
"default_scopes",
137137
"scopes",
138138
"default_task_classes",
139+
"vision_requirement",
139140
"preferred_action_kinds",
140141
"avoid_action_kinds",
141142
}

loopx/presentation/renderers/status_markdown.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1363,6 +1363,10 @@ def append_attention_queue_project_asset_markdown(
13631363
member_fields.append(f"profile_role={markdown_scalar(agent_member['profile_role'])}")
13641364
if agent_member.get("scope_summary"):
13651365
member_fields.append(f"scope={markdown_scalar(agent_member['scope_summary'])}")
1366+
if agent_member.get("vision_requirement"):
1367+
member_fields.append(
1368+
f"vision_requirement={markdown_scalar(agent_member['vision_requirement'])}"
1369+
)
13661370
member_fields.extend(
13671371
[
13681372
f"claims={markdown_scalar(current_claims)}",

loopx/quota.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1198,6 +1198,13 @@ def _effective_action(
11981198
return "quota_skip"
11991199

12001200

1201+
def _quota_agent_profile(agent_identity: dict[str, Any] | None) -> dict[str, Any] | None:
1202+
if not isinstance(agent_identity, dict):
1203+
return None
1204+
profile = agent_identity.get("agent_profile")
1205+
return profile if isinstance(profile, dict) else None
1206+
1207+
12011208
def build_quota_should_run(
12021209
status_payload: dict[str, Any],
12031210
*,
@@ -1414,6 +1421,7 @@ def build_quota_should_run(
14141421
neutral_replan_ack_classifications=AUTONOMOUS_REPLAN_ACK_NEUTRAL_CLASSIFICATIONS,
14151422
registered_agent_ids=registered_agent_ids,
14161423
goal_status=str(registry_goal.get("status") or ""),
1424+
agent_profile=_quota_agent_profile(agent_identity),
14171425
)
14181426
replan_obligation = goal_frontier_context.get("replan_obligation")
14191427
replan_scope = goal_frontier_context.get("replan_scope") or {}

0 commit comments

Comments
 (0)