"""Agent graph-growth: ``create_node`` action + relaxed agent-path caps. Covers the validator (new action + same-batch edge resolution + cap threading), the reducer (model_inferred node/claim + immediate visibility), and the ``apply_agent_actions`` density path (a multi-node turn beyond the legacy 4-action cap actually lands). """ from __future__ import annotations import time import uuid from loosecanvas import turn_logic from loosecanvas.contracts import ( Node, SceneAction, SceneActionType, ScenePlan, SceneState, SourceSnapshot, ) from loosecanvas.graph_repository import LooseGraphRepository from loosecanvas.reducer import reduce_turn from loosecanvas.validator import validate_sceneplan # ── Builders ────────────────────────────────────────────────────────────────── def _node(node_id: str) -> Node: return Node(id=node_id, kind="concept", label=node_id) def _graph(*node_ids: str) -> LooseGraphRepository: repo = LooseGraphRepository() repo.upsert_nodes([_node(nid) for nid in node_ids]) return repo def _scene( *, visible_nodes: list[str] | None = None, fogged: list[str] | None = None ) -> SceneState: return SceneState( visible_node_ids=visible_nodes or [], fogged_node_ids=fogged or [] ) def _create_node(node_id: str, label: str, note: str = "") -> SceneAction: return SceneAction( type=SceneActionType.create_node, target_id=node_id, label=label, note=note ) # ── Validator: create_node ────────────────────────────────────────────────── def test_create_node_valid() -> None: graph = _graph("a") scene = _scene(visible_nodes=["a"]) plan = ScenePlan(actions=[_create_node("concept::woody", "Woody Guthrie")]) result = validate_sceneplan(plan, graph, scene) assert len(result.valid_actions) == 1 assert result.rejected_actions == [] def test_create_node_missing_label_rejected() -> None: graph = _graph("a") scene = _scene(visible_nodes=["a"]) plan = ScenePlan(actions=[_create_node("concept::x", "")]) result = validate_sceneplan(plan, graph, scene) assert len(result.rejected_actions) == 1 assert "missing_required_field" in result.rejection_reasons[0] def test_create_node_existing_id_rejected() -> None: graph = _graph("a") scene = _scene(visible_nodes=["a"]) plan = ScenePlan(actions=[_create_node("a", "Clobber")]) result = validate_sceneplan(plan, graph, scene) assert len(result.rejected_actions) == 1 assert "id_exists" in result.rejection_reasons[0] def test_create_edge_resolves_node_created_earlier_in_same_plan() -> None: """A create_edge may reference a node create_node'd earlier in the same batch.""" graph = _graph("dylan") scene = _scene(visible_nodes=["dylan"]) plan = ScenePlan( actions=[ _create_node("concept::woody", "Woody Guthrie"), SceneAction( type=SceneActionType.create_edge, source_id="concept::woody", target_id="dylan", label="influenced", ), ] ) result = validate_sceneplan(plan, graph, scene) assert len(result.valid_actions) == 2 assert result.rejected_actions == [] # ── Validator: cap threading ───────────────────────────────────────────────── def test_default_action_cap_still_four() -> None: graph = _graph("a") scene = _scene(visible_nodes=["a"]) plan = ScenePlan( actions=[_create_node(f"concept::n{i}", f"N{i}") for i in range(6)] ) result = validate_sceneplan(plan, graph, scene) # Legacy default unchanged: only the first 4 survive. assert len(result.valid_actions) == 4 assert all("action_cap_exceeded" in r for r in result.rejection_reasons) def test_raised_action_cap_admits_more() -> None: graph = _graph("a") scene = _scene(visible_nodes=["a"]) plan = ScenePlan( actions=[_create_node(f"concept::n{i}", f"N{i}") for i in range(6)] ) result = validate_sceneplan(plan, graph, scene, max_actions=10) assert len(result.valid_actions) == 6 assert result.rejected_actions == [] # ── Reducer: trust + visibility ────────────────────────────────────────────── def test_create_node_emits_model_inferred_node_and_claim() -> None: graph = _graph("a") scene = _scene(visible_nodes=["a"]) action = _create_node("concept::woody", "Woody Guthrie", note="Folk forefather") gp, sp, _events = reduce_turn([action], graph, scene) node = next(n for n in gp.upsert_nodes if n.id == "concept::woody") assert node.kind == "concept" assert node.label == "Woody Guthrie" assert node.summary == "Folk forefather" assert node.properties.get("origin") == "model_inferred" claim = next(c for c in gp.upsert_claims if c.target_id == "concept::woody") assert claim.origin == "model_inferred" assert claim.support_state == "unverified" assert claim.review_state == "pending" # The new node is immediately visible so the agent can wire edges to it. assert "concept::woody" in sp.add_visible_node_ids def test_create_node_then_edge_reveals_edge_same_batch() -> None: graph = _graph("dylan") scene = _scene(visible_nodes=["dylan"]) actions = [ _create_node("concept::woody", "Woody Guthrie"), SceneAction( type=SceneActionType.create_edge, source_id="concept::woody", target_id="dylan", label="influenced", ), ] gp, sp, _events = reduce_turn(actions, graph, scene) assert "concept::woody" in sp.add_visible_node_ids new_edge = gp.upsert_edges[0] assert new_edge.id in sp.add_visible_edge_ids # ── apply_agent_actions: density beyond the legacy cap ─────────────────────── def _make_session() -> str: repo = LooseGraphRepository.from_extraction( [Node(id="dylan", kind="concept", label="Bob Dylan")], [], [], SourceSnapshot() ) scene = SceneState(scene_id="s1", visible_node_ids=["dylan"]) sid = str(uuid.uuid4()) now = time.time() turn_logic.session_store[sid] = turn_logic.SessionRecord( session_id=sid, graph=repo, scene=scene, history=[], graph_version=1, created_at=now, last_seen=now, render_scene_id="s1", ) return sid def test_apply_agent_actions_lands_dense_turn() -> None: sid = _make_session() record = turn_logic.session_store[sid] actions: list[SceneAction] = [] for i in range(8): actions.append(_create_node(f"concept::n{i}", f"Concept {i}")) actions.append( SceneAction( type=SceneActionType.create_edge, source_id=f"concept::n{i}", target_id="dylan", label="relates to", ) ) turn_logic.apply_agent_actions(record, actions, {}, "building a dense web") # All 8 new concept nodes landed (legacy 4-action cap would have dropped most). for i in range(8): assert record.graph.get_node(f"concept::n{i}") is not None del turn_logic.session_store[sid]