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
21 changes: 14 additions & 7 deletions grapharc/cli/init_cmd.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,8 @@

from __future__ import annotations

from typing import Any
import operator
from typing import Annotated, Any

from pydantic import BaseModel

Expand DownExpand Up@@ -87,7 +88,13 @@

class State(BaseModel):
goal: str = "" # filled from the CLI argument; the planner reads it
notes: list[str] = [] # the working record every kind appends to
# `notes` is a REDUCER (Annotated + operator.add): each writer returns just
# its own lines and LangGraph merges them, so two nodes — or two
# planner-named instances of ONE kind — may write it in the same parallel
# step. A plain `list[str]` here crashes the first time a planner runs two
# writers concurrently (InvalidUpdateError); keep the pattern for any field
# more than one node may write.
notes: Annotated[list[str], operator.add] = []
report: str = "" # the deliverable; the goal check below watches it


Expand All@@ -111,7 +118,7 @@ def _gather(state: State) -> dict:
f"({', '.join(dirs[:12]) or 'none'}) and {len(files)} file(s) "
f"({', '.join(files[:12]) or 'none'})"
)
return {"notes": [*state.notes, note]}
return {"notes": [note]}


def _analyse(state: State) -> dict:
Expand All@@ -126,7 +133,7 @@ def _analyse(state: State) -> dict:
note = "analyse: file types by count — " + ", ".join(
f"{ext} x{count}" for ext, count in top
)
return {"notes": [*state.notes, note]}
return {"notes": [note]}


def _report_for(model: Any):
Expand All@@ -142,7 +149,7 @@ def body(state: State) -> dict:
if model is None or scripted or not hasattr(model, "invoke"):
return {
"report": "report: run with --model SPEC for a model-written report",
"notes": [*state.notes, "report: written without a model"],
"notes": ["report: written without a model"],
}
try:
reply = model.invoke(
Expand All@@ -153,7 +160,7 @@ def body(state: State) -> dict:
text = str(getattr(reply, "content", reply)).strip()[:2000]
except Exception as exc: # a failed call is a note, not a crash
text = f"report: model call failed ({exc}); notes stand"
return {"report": text, "notes": [*state.notes, "report: written"]}
return {"report": text, "notes": ["report: written"]}

return body

Expand All@@ -164,7 +171,7 @@ def _apply(state: State) -> dict:
propose it) and DENIED by the edge policy below (no admitted graph may
reach it) until you decide otherwise. Keep the pattern even after you
rename it: a gate with nothing to refuse proves nothing."""
return {"notes": [*state.notes, "apply: this should not have run"]}
return {"notes": ["apply: this should not have run"]}


# ── 3. Write permissions ────────────────────────────────────────────────────
Expand Down
16 changes: 12 additions & 4 deletions grapharc/examples/plan_incident.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,8 @@
from __future__ import annotations

import json
from typing import Any
import operator
from typing import Annotated, Any

from pydantic import BaseModel

Expand All@@ -44,10 +45,17 @@


class IncidentState(BaseModel):
"""One state contract for the whole run, however the topology changes."""
"""One state contract for the whole run, however the topology changes.

`notes` is a reducer (`Annotated` + `operator.add`): each writer returns
only its own lines and LangGraph merges them, so a planner that runs two
writers in the same parallel step — including two instances of one kind —
composes instead of colliding. A plain `list[str]` here raises
`InvalidUpdateError` the first time that happens.
"""

goal: str = ""
notes: list[str] = []
notes: Annotated[list[str], operator.add] = []


def _step_factory(spec: NodeSpec) -> Any:
Expand All@@ -59,7 +67,7 @@ def _step_factory(spec: NodeSpec) -> Any:
"""

def body(state: IncidentState) -> dict:
return {"notes": [*state.notes, f"{spec.name} ran"]}
return {"notes": [f"{spec.name} ran"]}

body.writes = {"notes"}
return body
Expand Down
37 changes: 37 additions & 0 deletions tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2198,6 +2198,43 @@ def test_an_init_scaffold_plans_end_to_end(tmp_path, monkeypatch, capsys):
assert "goal_met" in printed


def test_the_scaffold_state_merges_parallel_writers(tmp_path, monkeypatch):
"""Two kinds writing `notes` in the same superstep compose via the reducer.

The shape any real planner eventually proposes: `gather` and `analyse`
both fanned out of START, joining at `report`. With a plain `list[str]`
this run died on LangGraph's InvalidUpdateError before `report` ever ran;
the scaffold's `notes` is a reducer now, and this test is what keeps it
one.
"""
monkeypatch.chdir(tmp_path)
from grapharc.cli.init_cmd import REGISTRY_TEMPLATE
from grapharc.testing import ScriptedChatModel

module = ModuleType("scaffold_registry")
# The path-form loader registers the module before executing it, and
# pydantic needs that to resolve the template's deferred annotations.
monkeypatch.setitem(sys.modules, "scaffold_registry", module)
exec(compile(REGISTRY_TEMPLATE, "registry.py", "exec"), module.__dict__)
plan = json.dumps(
{
"nodes": [{"name": "gather"}, {"name": "analyse"}, {"name": "report"}],
"edges": [
{"source": "__start__", "target": "gather"},
{"source": "__start__", "target": "analyse"},
{"source": "gather", "target": "report"},
{"source": "analyse", "target": "report"},
{"source": "report", "target": "__end__"},
],
}
)
loop = module.build_loop(ScriptedChatModel(responses=[plan]))
result = loop.run("report on this directory, twice over", module.State())
assert result.stop.value == "goal_met"
assert any(note.startswith("gather:") for note in result.state.notes)
assert any(note.startswith("analyse:") for note in result.state.notes)


def test_the_path_form_registry_shares_one_module_object(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "reg.py").write_text(
Expand Down
29 changes: 29 additions & 0 deletions tests/test_planner_loop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1632,3 +1632,32 @@ def test_the_disclosure_is_not_what_refuses_the_edge():
assert [r.model_dump() for r in with_disclosure.rejections()] == [
r.model_dump() for r in without.rejections()
]


def test_the_incident_example_state_merges_parallel_writers():
"""Three kinds writing `notes` in one superstep compose via the reducer.

The shipped example's state used a plain `list[str]`, so the first plan
that fanned kinds out of START died on LangGraph's InvalidUpdateError.
`IncidentState.notes` is a reducer now; this run is the shape that broke.
"""
from grapharc.examples.plan_incident import IncidentState
from grapharc.examples.plan_incident import build_loop as build_incident_loop

fan_out = json.dumps(
{
"nodes": [{"name": "triage"}, {"name": "patch"}, {"name": "verify"}],
"edges": [
{"source": "__start__", "target": "triage"},
{"source": "__start__", "target": "patch"},
{"source": "__start__", "target": "verify"},
{"source": "triage", "target": "__end__"},
{"source": "patch", "target": "__end__"},
{"source": "verify", "target": "__end__"},
],
}
)
loop = build_incident_loop(ScriptedChatModel(responses=[fan_out]))
result = loop.run("triage, patch and verify at once", IncidentState())
assert result.stop.value == "goal_met"
assert sorted(result.state.notes) == ["patch ran", "triage ran", "verify ran"]
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
21 changes: 14 additions & 7 deletions grapharc/cli/init_cmd.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,8 @@

from __future__ import annotations

from typing import Any
import operator
from typing import Annotated, Any

from pydantic import BaseModel

Expand DownExpand Up@@ -87,7 +88,13 @@

class State(BaseModel):
goal: str = "" # filled from the CLI argument; the planner reads it
notes: list[str] = [] # the working record every kind appends to
# `notes` is a REDUCER (Annotated + operator.add): each writer returns just
# its own lines and LangGraph merges them, so two nodes — or two
# planner-named instances of ONE kind — may write it in the same parallel
# step. A plain `list[str]` here crashes the first time a planner runs two
# writers concurrently (InvalidUpdateError); keep the pattern for any field
# more than one node may write.
notes: Annotated[list[str], operator.add] = []
report: str = "" # the deliverable; the goal check below watches it


Expand All@@ -111,7 +118,7 @@ def _gather(state: State) -> dict:
f"({', '.join(dirs[:12]) or 'none'}) and {len(files)} file(s) "
f"({', '.join(files[:12]) or 'none'})"
)
return {"notes": [*state.notes, note]}
return {"notes": [note]}


def _analyse(state: State) -> dict:
Expand All@@ -126,7 +133,7 @@ def _analyse(state: State) -> dict:
note = "analyse: file types by count — " + ", ".join(
f"{ext} x{count}" for ext, count in top
)
return {"notes": [*state.notes, note]}
return {"notes": [note]}


def _report_for(model: Any):
Expand All@@ -142,7 +149,7 @@ def body(state: State) -> dict:
if model is None or scripted or not hasattr(model, "invoke"):
return {
"report": "report: run with --model SPEC for a model-written report",
"notes": [*state.notes, "report: written without a model"],
"notes": ["report: written without a model"],
}
try:
reply = model.invoke(
Expand All@@ -153,7 +160,7 @@ def body(state: State) -> dict:
text = str(getattr(reply, "content", reply)).strip()[:2000]
except Exception as exc: # a failed call is a note, not a crash
text = f"report: model call failed ({exc}); notes stand"
return {"report": text, "notes": [*state.notes, "report: written"]}
return {"report": text, "notes": ["report: written"]}

return body

Expand All@@ -164,7 +171,7 @@ def _apply(state: State) -> dict:
propose it) and DENIED by the edge policy below (no admitted graph may
reach it) until you decide otherwise. Keep the pattern even after you
rename it: a gate with nothing to refuse proves nothing."""
return {"notes": [*state.notes, "apply: this should not have run"]}
return {"notes": ["apply: this should not have run"]}


# ── 3. Write permissions ────────────────────────────────────────────────────
Expand Down
16 changes: 12 additions & 4 deletions grapharc/examples/plan_incident.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,8 @@
from __future__ import annotations

import json
from typing import Any
import operator
from typing import Annotated, Any

from pydantic import BaseModel

Expand All@@ -44,10 +45,17 @@


class IncidentState(BaseModel):
"""One state contract for the whole run, however the topology changes."""
"""One state contract for the whole run, however the topology changes.

`notes` is a reducer (`Annotated` + `operator.add`): each writer returns
only its own lines and LangGraph merges them, so a planner that runs two
writers in the same parallel step — including two instances of one kind —
composes instead of colliding. A plain `list[str]` here raises
`InvalidUpdateError` the first time that happens.
"""

goal: str = ""
notes: list[str] = []
notes: Annotated[list[str], operator.add] = []


def _step_factory(spec: NodeSpec) -> Any:
Expand All@@ -59,7 +67,7 @@ def _step_factory(spec: NodeSpec) -> Any:
"""

def body(state: IncidentState) -> dict:
return {"notes": [*state.notes, f"{spec.name} ran"]}
return {"notes": [f"{spec.name} ran"]}

body.writes = {"notes"}
return body
Expand Down
37 changes: 37 additions & 0 deletions tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2198,6 +2198,43 @@ def test_an_init_scaffold_plans_end_to_end(tmp_path, monkeypatch, capsys):
assert "goal_met" in printed


def test_the_scaffold_state_merges_parallel_writers(tmp_path, monkeypatch):
"""Two kinds writing `notes` in the same superstep compose via the reducer.

The shape any real planner eventually proposes: `gather` and `analyse`
both fanned out of START, joining at `report`. With a plain `list[str]`
this run died on LangGraph's InvalidUpdateError before `report` ever ran;
the scaffold's `notes` is a reducer now, and this test is what keeps it
one.
"""
monkeypatch.chdir(tmp_path)
from grapharc.cli.init_cmd import REGISTRY_TEMPLATE
from grapharc.testing import ScriptedChatModel

module = ModuleType("scaffold_registry")
# The path-form loader registers the module before executing it, and
# pydantic needs that to resolve the template's deferred annotations.
monkeypatch.setitem(sys.modules, "scaffold_registry", module)
exec(compile(REGISTRY_TEMPLATE, "registry.py", "exec"), module.__dict__)
plan = json.dumps(
{
"nodes": [{"name": "gather"}, {"name": "analyse"}, {"name": "report"}],
"edges": [
{"source": "__start__", "target": "gather"},
{"source": "__start__", "target": "analyse"},
{"source": "gather", "target": "report"},
{"source": "analyse", "target": "report"},
{"source": "report", "target": "__end__"},
],
}
)
loop = module.build_loop(ScriptedChatModel(responses=[plan]))
result = loop.run("report on this directory, twice over", module.State())
assert result.stop.value == "goal_met"
assert any(note.startswith("gather:") for note in result.state.notes)
assert any(note.startswith("analyse:") for note in result.state.notes)


def test_the_path_form_registry_shares_one_module_object(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "reg.py").write_text(
Expand Down
29 changes: 29 additions & 0 deletions tests/test_planner_loop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1632,3 +1632,32 @@ def test_the_disclosure_is_not_what_refuses_the_edge():
assert [r.model_dump() for r in with_disclosure.rejections()] == [
r.model_dump() for r in without.rejections()
]


def test_the_incident_example_state_merges_parallel_writers():
"""Three kinds writing `notes` in one superstep compose via the reducer.

The shipped example's state used a plain `list[str]`, so the first plan
that fanned kinds out of START died on LangGraph's InvalidUpdateError.
`IncidentState.notes` is a reducer now; this run is the shape that broke.
"""
from grapharc.examples.plan_incident import IncidentState
from grapharc.examples.plan_incident import build_loop as build_incident_loop

fan_out = json.dumps(
{
"nodes": [{"name": "triage"}, {"name": "patch"}, {"name": "verify"}],
"edges": [
{"source": "__start__", "target": "triage"},
{"source": "__start__", "target": "patch"},
{"source": "__start__", "target": "verify"},
{"source": "triage", "target": "__end__"},
{"source": "patch", "target": "__end__"},
{"source": "verify", "target": "__end__"},
],
}
)
loop = build_incident_loop(ScriptedChatModel(responses=[fan_out]))
result = loop.run("triage, patch and verify at once", IncidentState())
assert result.stop.value == "goal_met"
assert sorted(result.state.notes) == ["patch ran", "triage ran", "verify ran"]
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
21 changes: 14 additions & 7 deletions grapharc/cli/init_cmd.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,8 @@

from __future__ import annotations

from typing import Any
import operator
from typing import Annotated, Any

from pydantic import BaseModel

Expand DownExpand Up@@ -87,7 +88,13 @@

class State(BaseModel):
goal: str = "" # filled from the CLI argument; the planner reads it
notes: list[str] = [] # the working record every kind appends to
# `notes` is a REDUCER (Annotated + operator.add): each writer returns just
# its own lines and LangGraph merges them, so two nodes — or two
# planner-named instances of ONE kind — may write it in the same parallel
# step. A plain `list[str]` here crashes the first time a planner runs two
# writers concurrently (InvalidUpdateError); keep the pattern for any field
# more than one node may write.
notes: Annotated[list[str], operator.add] = []
report: str = "" # the deliverable; the goal check below watches it


Expand All@@ -111,7 +118,7 @@ def _gather(state: State) -> dict:
f"({', '.join(dirs[:12]) or 'none'}) and {len(files)} file(s) "
f"({', '.join(files[:12]) or 'none'})"
)
return {"notes": [*state.notes, note]}
return {"notes": [note]}


def _analyse(state: State) -> dict:
Expand All@@ -126,7 +133,7 @@ def _analyse(state: State) -> dict:
note = "analyse: file types by count — " + ", ".join(
f"{ext} x{count}" for ext, count in top
)
return {"notes": [*state.notes, note]}
return {"notes": [note]}


def _report_for(model: Any):
Expand All@@ -142,7 +149,7 @@ def body(state: State) -> dict:
if model is None or scripted or not hasattr(model, "invoke"):
return {
"report": "report: run with --model SPEC for a model-written report",
"notes": [*state.notes, "report: written without a model"],
"notes": ["report: written without a model"],
}
try:
reply = model.invoke(
Expand All@@ -153,7 +160,7 @@ def body(state: State) -> dict:
text = str(getattr(reply, "content", reply)).strip()[:2000]
except Exception as exc: # a failed call is a note, not a crash
text = f"report: model call failed ({exc}); notes stand"
return {"report": text, "notes": [*state.notes, "report: written"]}
return {"report": text, "notes": ["report: written"]}

return body

Expand All@@ -164,7 +171,7 @@ def _apply(state: State) -> dict:
propose it) and DENIED by the edge policy below (no admitted graph may
reach it) until you decide otherwise. Keep the pattern even after you
rename it: a gate with nothing to refuse proves nothing."""
return {"notes": [*state.notes, "apply: this should not have run"]}
return {"notes": ["apply: this should not have run"]}


# ── 3. Write permissions ────────────────────────────────────────────────────
Expand Down
16 changes: 12 additions & 4 deletions grapharc/examples/plan_incident.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,8 @@
from __future__ import annotations

import json
from typing import Any
import operator
from typing import Annotated, Any

from pydantic import BaseModel

Expand All@@ -44,10 +45,17 @@


class IncidentState(BaseModel):
"""One state contract for the whole run, however the topology changes."""
"""One state contract for the whole run, however the topology changes.

`notes` is a reducer (`Annotated` + `operator.add`): each writer returns
only its own lines and LangGraph merges them, so a planner that runs two
writers in the same parallel step — including two instances of one kind —
composes instead of colliding. A plain `list[str]` here raises
`InvalidUpdateError` the first time that happens.
"""

goal: str = ""
notes: list[str] = []
notes: Annotated[list[str], operator.add] = []


def _step_factory(spec: NodeSpec) -> Any:
Expand All@@ -59,7 +67,7 @@ def _step_factory(spec: NodeSpec) -> Any:
"""

def body(state: IncidentState) -> dict:
return {"notes": [*state.notes, f"{spec.name} ran"]}
return {"notes": [f"{spec.name} ran"]}

body.writes = {"notes"}
return body
Expand Down
37 changes: 37 additions & 0 deletions tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2198,6 +2198,43 @@ def test_an_init_scaffold_plans_end_to_end(tmp_path, monkeypatch, capsys):
assert "goal_met" in printed


def test_the_scaffold_state_merges_parallel_writers(tmp_path, monkeypatch):
"""Two kinds writing `notes` in the same superstep compose via the reducer.

The shape any real planner eventually proposes: `gather` and `analyse`
both fanned out of START, joining at `report`. With a plain `list[str]`
this run died on LangGraph's InvalidUpdateError before `report` ever ran;
the scaffold's `notes` is a reducer now, and this test is what keeps it
one.
"""
monkeypatch.chdir(tmp_path)
from grapharc.cli.init_cmd import REGISTRY_TEMPLATE
from grapharc.testing import ScriptedChatModel

module = ModuleType("scaffold_registry")
# The path-form loader registers the module before executing it, and
# pydantic needs that to resolve the template's deferred annotations.
monkeypatch.setitem(sys.modules, "scaffold_registry", module)
exec(compile(REGISTRY_TEMPLATE, "registry.py", "exec"), module.__dict__)
plan = json.dumps(
{
"nodes": [{"name": "gather"}, {"name": "analyse"}, {"name": "report"}],
"edges": [
{"source": "__start__", "target": "gather"},
{"source": "__start__", "target": "analyse"},
{"source": "gather", "target": "report"},
{"source": "analyse", "target": "report"},
{"source": "report", "target": "__end__"},
],
}
)
loop = module.build_loop(ScriptedChatModel(responses=[plan]))
result = loop.run("report on this directory, twice over", module.State())
assert result.stop.value == "goal_met"
assert any(note.startswith("gather:") for note in result.state.notes)
assert any(note.startswith("analyse:") for note in result.state.notes)


def test_the_path_form_registry_shares_one_module_object(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "reg.py").write_text(
Expand Down
29 changes: 29 additions & 0 deletions tests/test_planner_loop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1632,3 +1632,32 @@ def test_the_disclosure_is_not_what_refuses_the_edge():
assert [r.model_dump() for r in with_disclosure.rejections()] == [
r.model_dump() for r in without.rejections()
]


def test_the_incident_example_state_merges_parallel_writers():
"""Three kinds writing `notes` in one superstep compose via the reducer.

The shipped example's state used a plain `list[str]`, so the first plan
that fanned kinds out of START died on LangGraph's InvalidUpdateError.
`IncidentState.notes` is a reducer now; this run is the shape that broke.
"""
from grapharc.examples.plan_incident import IncidentState
from grapharc.examples.plan_incident import build_loop as build_incident_loop

fan_out = json.dumps(
{
"nodes": [{"name": "triage"}, {"name": "patch"}, {"name": "verify"}],
"edges": [
{"source": "__start__", "target": "triage"},
{"source": "__start__", "target": "patch"},
{"source": "__start__", "target": "verify"},
{"source": "triage", "target": "__end__"},
{"source": "patch", "target": "__end__"},
{"source": "verify", "target": "__end__"},
],
}
)
loop = build_incident_loop(ScriptedChatModel(responses=[fan_out]))
result = loop.run("triage, patch and verify at once", IncidentState())
assert result.stop.value == "goal_met"
assert sorted(result.state.notes) == ["patch ran", "triage ran", "verify ran"]
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
21 changes: 14 additions & 7 deletions grapharc/cli/init_cmd.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,8 @@

from __future__ import annotations

from typing import Any
import operator
from typing import Annotated, Any

from pydantic import BaseModel

Expand DownExpand Up@@ -87,7 +88,13 @@

class State(BaseModel):
goal: str = "" # filled from the CLI argument; the planner reads it
notes: list[str] = [] # the working record every kind appends to
# `notes` is a REDUCER (Annotated + operator.add): each writer returns just
# its own lines and LangGraph merges them, so two nodes — or two
# planner-named instances of ONE kind — may write it in the same parallel
# step. A plain `list[str]` here crashes the first time a planner runs two
# writers concurrently (InvalidUpdateError); keep the pattern for any field
# more than one node may write.
notes: Annotated[list[str], operator.add] = []
report: str = "" # the deliverable; the goal check below watches it


Expand All@@ -111,7 +118,7 @@ def _gather(state: State) -> dict:
f"({', '.join(dirs[:12]) or 'none'}) and {len(files)} file(s) "
f"({', '.join(files[:12]) or 'none'})"
)
return {"notes": [*state.notes, note]}
return {"notes": [note]}


def _analyse(state: State) -> dict:
Expand All@@ -126,7 +133,7 @@ def _analyse(state: State) -> dict:
note = "analyse: file types by count — " + ", ".join(
f"{ext} x{count}" for ext, count in top
)
return {"notes": [*state.notes, note]}
return {"notes": [note]}


def _report_for(model: Any):
Expand All@@ -142,7 +149,7 @@ def body(state: State) -> dict:
if model is None or scripted or not hasattr(model, "invoke"):
return {
"report": "report: run with --model SPEC for a model-written report",
"notes": [*state.notes, "report: written without a model"],
"notes": ["report: written without a model"],
}
try:
reply = model.invoke(
Expand All@@ -153,7 +160,7 @@ def body(state: State) -> dict:
text = str(getattr(reply, "content", reply)).strip()[:2000]
except Exception as exc: # a failed call is a note, not a crash
text = f"report: model call failed ({exc}); notes stand"
return {"report": text, "notes": [*state.notes, "report: written"]}
return {"report": text, "notes": ["report: written"]}

return body

Expand All@@ -164,7 +171,7 @@ def _apply(state: State) -> dict:
propose it) and DENIED by the edge policy below (no admitted graph may
reach it) until you decide otherwise. Keep the pattern even after you
rename it: a gate with nothing to refuse proves nothing."""
return {"notes": [*state.notes, "apply: this should not have run"]}
return {"notes": ["apply: this should not have run"]}


# ── 3. Write permissions ────────────────────────────────────────────────────
Expand Down
16 changes: 12 additions & 4 deletions grapharc/examples/plan_incident.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,8 @@
from __future__ import annotations

import json
from typing import Any
import operator
from typing import Annotated, Any

from pydantic import BaseModel

Expand All@@ -44,10 +45,17 @@


class IncidentState(BaseModel):
"""One state contract for the whole run, however the topology changes."""
"""One state contract for the whole run, however the topology changes.

`notes` is a reducer (`Annotated` + `operator.add`): each writer returns
only its own lines and LangGraph merges them, so a planner that runs two
writers in the same parallel step — including two instances of one kind —
composes instead of colliding. A plain `list[str]` here raises
`InvalidUpdateError` the first time that happens.
"""

goal: str = ""
notes: list[str] = []
notes: Annotated[list[str], operator.add] = []


def _step_factory(spec: NodeSpec) -> Any:
Expand All@@ -59,7 +67,7 @@ def _step_factory(spec: NodeSpec) -> Any:
"""

def body(state: IncidentState) -> dict:
return {"notes": [*state.notes, f"{spec.name} ran"]}
return {"notes": [f"{spec.name} ran"]}

body.writes = {"notes"}
return body
Expand Down
37 changes: 37 additions & 0 deletions tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2198,6 +2198,43 @@ def test_an_init_scaffold_plans_end_to_end(tmp_path, monkeypatch, capsys):
assert "goal_met" in printed


def test_the_scaffold_state_merges_parallel_writers(tmp_path, monkeypatch):
"""Two kinds writing `notes` in the same superstep compose via the reducer.

The shape any real planner eventually proposes: `gather` and `analyse`
both fanned out of START, joining at `report`. With a plain `list[str]`
this run died on LangGraph's InvalidUpdateError before `report` ever ran;
the scaffold's `notes` is a reducer now, and this test is what keeps it
one.
"""
monkeypatch.chdir(tmp_path)
from grapharc.cli.init_cmd import REGISTRY_TEMPLATE
from grapharc.testing import ScriptedChatModel

module = ModuleType("scaffold_registry")
# The path-form loader registers the module before executing it, and
# pydantic needs that to resolve the template's deferred annotations.
monkeypatch.setitem(sys.modules, "scaffold_registry", module)
exec(compile(REGISTRY_TEMPLATE, "registry.py", "exec"), module.__dict__)
plan = json.dumps(
{
"nodes": [{"name": "gather"}, {"name": "analyse"}, {"name": "report"}],
"edges": [
{"source": "__start__", "target": "gather"},
{"source": "__start__", "target": "analyse"},
{"source": "gather", "target": "report"},
{"source": "analyse", "target": "report"},
{"source": "report", "target": "__end__"},
],
}
)
loop = module.build_loop(ScriptedChatModel(responses=[plan]))
result = loop.run("report on this directory, twice over", module.State())
assert result.stop.value == "goal_met"
assert any(note.startswith("gather:") for note in result.state.notes)
assert any(note.startswith("analyse:") for note in result.state.notes)


def test_the_path_form_registry_shares_one_module_object(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "reg.py").write_text(
Expand Down
29 changes: 29 additions & 0 deletions tests/test_planner_loop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1632,3 +1632,32 @@ def test_the_disclosure_is_not_what_refuses_the_edge():
assert [r.model_dump() for r in with_disclosure.rejections()] == [
r.model_dump() for r in without.rejections()
]


def test_the_incident_example_state_merges_parallel_writers():
"""Three kinds writing `notes` in one superstep compose via the reducer.

The shipped example's state used a plain `list[str]`, so the first plan
that fanned kinds out of START died on LangGraph's InvalidUpdateError.
`IncidentState.notes` is a reducer now; this run is the shape that broke.
"""
from grapharc.examples.plan_incident import IncidentState
from grapharc.examples.plan_incident import build_loop as build_incident_loop

fan_out = json.dumps(
{
"nodes": [{"name": "triage"}, {"name": "patch"}, {"name": "verify"}],
"edges": [
{"source": "__start__", "target": "triage"},
{"source": "__start__", "target": "patch"},
{"source": "__start__", "target": "verify"},
{"source": "triage", "target": "__end__"},
{"source": "patch", "target": "__end__"},
{"source": "verify", "target": "__end__"},
],
}
)
loop = build_incident_loop(ScriptedChatModel(responses=[fan_out]))
result = loop.run("triage, patch and verify at once", IncidentState())
assert result.stop.value == "goal_met"
assert sorted(result.state.notes) == ["patch ran", "triage ran", "verify ran"]
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
21 changes: 14 additions & 7 deletions grapharc/cli/init_cmd.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,8 @@

from __future__ import annotations

from typing import Any
import operator
from typing import Annotated, Any

from pydantic import BaseModel

Expand DownExpand Up@@ -87,7 +88,13 @@

class State(BaseModel):
goal: str = "" # filled from the CLI argument; the planner reads it
notes: list[str] = [] # the working record every kind appends to
# `notes` is a REDUCER (Annotated + operator.add): each writer returns just
# its own lines and LangGraph merges them, so two nodes — or two
# planner-named instances of ONE kind — may write it in the same parallel
# step. A plain `list[str]` here crashes the first time a planner runs two
# writers concurrently (InvalidUpdateError); keep the pattern for any field
# more than one node may write.
notes: Annotated[list[str], operator.add] = []
report: str = "" # the deliverable; the goal check below watches it


Expand All@@ -111,7 +118,7 @@ def _gather(state: State) -> dict:
f"({', '.join(dirs[:12]) or 'none'}) and {len(files)} file(s) "
f"({', '.join(files[:12]) or 'none'})"
)
return {"notes": [*state.notes, note]}
return {"notes": [note]}


def _analyse(state: State) -> dict:
Expand All@@ -126,7 +133,7 @@ def _analyse(state: State) -> dict:
note = "analyse: file types by count — " + ", ".join(
f"{ext} x{count}" for ext, count in top
)
return {"notes": [*state.notes, note]}
return {"notes": [note]}


def _report_for(model: Any):
Expand All@@ -142,7 +149,7 @@ def body(state: State) -> dict:
if model is None or scripted or not hasattr(model, "invoke"):
return {
"report": "report: run with --model SPEC for a model-written report",
"notes": [*state.notes, "report: written without a model"],
"notes": ["report: written without a model"],
}
try:
reply = model.invoke(
Expand All@@ -153,7 +160,7 @@ def body(state: State) -> dict:
text = str(getattr(reply, "content", reply)).strip()[:2000]
except Exception as exc: # a failed call is a note, not a crash
text = f"report: model call failed ({exc}); notes stand"
return {"report": text, "notes": [*state.notes, "report: written"]}
return {"report": text, "notes": ["report: written"]}

return body

Expand All@@ -164,7 +171,7 @@ def _apply(state: State) -> dict:
propose it) and DENIED by the edge policy below (no admitted graph may
reach it) until you decide otherwise. Keep the pattern even after you
rename it: a gate with nothing to refuse proves nothing."""
return {"notes": [*state.notes, "apply: this should not have run"]}
return {"notes": ["apply: this should not have run"]}


# ── 3. Write permissions ────────────────────────────────────────────────────
Expand Down
16 changes: 12 additions & 4 deletions grapharc/examples/plan_incident.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,8 @@
from __future__ import annotations

import json
from typing import Any
import operator
from typing import Annotated, Any

from pydantic import BaseModel

Expand All@@ -44,10 +45,17 @@


class IncidentState(BaseModel):
"""One state contract for the whole run, however the topology changes."""
"""One state contract for the whole run, however the topology changes.

`notes` is a reducer (`Annotated` + `operator.add`): each writer returns
only its own lines and LangGraph merges them, so a planner that runs two
writers in the same parallel step — including two instances of one kind —
composes instead of colliding. A plain `list[str]` here raises
`InvalidUpdateError` the first time that happens.
"""

goal: str = ""
notes: list[str] = []
notes: Annotated[list[str], operator.add] = []


def _step_factory(spec: NodeSpec) -> Any:
Expand All@@ -59,7 +67,7 @@ def _step_factory(spec: NodeSpec) -> Any:
"""

def body(state: IncidentState) -> dict:
return {"notes": [*state.notes, f"{spec.name} ran"]}
return {"notes": [f"{spec.name} ran"]}

body.writes = {"notes"}
return body
Expand Down
37 changes: 37 additions & 0 deletions tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2198,6 +2198,43 @@ def test_an_init_scaffold_plans_end_to_end(tmp_path, monkeypatch, capsys):
assert "goal_met" in printed


def test_the_scaffold_state_merges_parallel_writers(tmp_path, monkeypatch):
"""Two kinds writing `notes` in the same superstep compose via the reducer.

The shape any real planner eventually proposes: `gather` and `analyse`
both fanned out of START, joining at `report`. With a plain `list[str]`
this run died on LangGraph's InvalidUpdateError before `report` ever ran;
the scaffold's `notes` is a reducer now, and this test is what keeps it
one.
"""
monkeypatch.chdir(tmp_path)
from grapharc.cli.init_cmd import REGISTRY_TEMPLATE
from grapharc.testing import ScriptedChatModel

module = ModuleType("scaffold_registry")
# The path-form loader registers the module before executing it, and
# pydantic needs that to resolve the template's deferred annotations.
monkeypatch.setitem(sys.modules, "scaffold_registry", module)
exec(compile(REGISTRY_TEMPLATE, "registry.py", "exec"), module.__dict__)
plan = json.dumps(
{
"nodes": [{"name": "gather"}, {"name": "analyse"}, {"name": "report"}],
"edges": [
{"source": "__start__", "target": "gather"},
{"source": "__start__", "target": "analyse"},
{"source": "gather", "target": "report"},
{"source": "analyse", "target": "report"},
{"source": "report", "target": "__end__"},
],
}
)
loop = module.build_loop(ScriptedChatModel(responses=[plan]))
result = loop.run("report on this directory, twice over", module.State())
assert result.stop.value == "goal_met"
assert any(note.startswith("gather:") for note in result.state.notes)
assert any(note.startswith("analyse:") for note in result.state.notes)


def test_the_path_form_registry_shares_one_module_object(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "reg.py").write_text(
Expand Down
29 changes: 29 additions & 0 deletions tests/test_planner_loop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1632,3 +1632,32 @@ def test_the_disclosure_is_not_what_refuses_the_edge():
assert [r.model_dump() for r in with_disclosure.rejections()] == [
r.model_dump() for r in without.rejections()
]


def test_the_incident_example_state_merges_parallel_writers():
"""Three kinds writing `notes` in one superstep compose via the reducer.

The shipped example's state used a plain `list[str]`, so the first plan
that fanned kinds out of START died on LangGraph's InvalidUpdateError.
`IncidentState.notes` is a reducer now; this run is the shape that broke.
"""
from grapharc.examples.plan_incident import IncidentState
from grapharc.examples.plan_incident import build_loop as build_incident_loop

fan_out = json.dumps(
{
"nodes": [{"name": "triage"}, {"name": "patch"}, {"name": "verify"}],
"edges": [
{"source": "__start__", "target": "triage"},
{"source": "__start__", "target": "patch"},
{"source": "__start__", "target": "verify"},
{"source": "triage", "target": "__end__"},
{"source": "patch", "target": "__end__"},
{"source": "verify", "target": "__end__"},
],
}
)
loop = build_incident_loop(ScriptedChatModel(responses=[fan_out]))
result = loop.run("triage, patch and verify at once", IncidentState())
assert result.stop.value == "goal_met"
assert sorted(result.state.notes) == ["patch ran", "triage ran", "verify ran"]
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
21 changes: 14 additions & 7 deletions grapharc/cli/init_cmd.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,8 @@

from __future__ import annotations

from typing import Any
import operator
from typing import Annotated, Any

from pydantic import BaseModel

Expand DownExpand Up@@ -87,7 +88,13 @@

class State(BaseModel):
goal: str = "" # filled from the CLI argument; the planner reads it
notes: list[str] = [] # the working record every kind appends to
# `notes` is a REDUCER (Annotated + operator.add): each writer returns just
# its own lines and LangGraph merges them, so two nodes — or two
# planner-named instances of ONE kind — may write it in the same parallel
# step. A plain `list[str]` here crashes the first time a planner runs two
# writers concurrently (InvalidUpdateError); keep the pattern for any field
# more than one node may write.
notes: Annotated[list[str], operator.add] = []
report: str = "" # the deliverable; the goal check below watches it


Expand All@@ -111,7 +118,7 @@ def _gather(state: State) -> dict:
f"({', '.join(dirs[:12]) or 'none'}) and {len(files)} file(s) "
f"({', '.join(files[:12]) or 'none'})"
)
return {"notes": [*state.notes, note]}
return {"notes": [note]}


def _analyse(state: State) -> dict:
Expand All@@ -126,7 +133,7 @@ def _analyse(state: State) -> dict:
note = "analyse: file types by count — " + ", ".join(
f"{ext} x{count}" for ext, count in top
)
return {"notes": [*state.notes, note]}
return {"notes": [note]}


def _report_for(model: Any):
Expand All@@ -142,7 +149,7 @@ def body(state: State) -> dict:
if model is None or scripted or not hasattr(model, "invoke"):
return {
"report": "report: run with --model SPEC for a model-written report",
"notes": [*state.notes, "report: written without a model"],
"notes": ["report: written without a model"],
}
try:
reply = model.invoke(
Expand All@@ -153,7 +160,7 @@ def body(state: State) -> dict:
text = str(getattr(reply, "content", reply)).strip()[:2000]
except Exception as exc: # a failed call is a note, not a crash
text = f"report: model call failed ({exc}); notes stand"
return {"report": text, "notes": [*state.notes, "report: written"]}
return {"report": text, "notes": ["report: written"]}

return body

Expand All@@ -164,7 +171,7 @@ def _apply(state: State) -> dict:
propose it) and DENIED by the edge policy below (no admitted graph may
reach it) until you decide otherwise. Keep the pattern even after you
rename it: a gate with nothing to refuse proves nothing."""
return {"notes": [*state.notes, "apply: this should not have run"]}
return {"notes": ["apply: this should not have run"]}


# ── 3. Write permissions ────────────────────────────────────────────────────
Expand Down
16 changes: 12 additions & 4 deletions grapharc/examples/plan_incident.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,8 @@
from __future__ import annotations

import json
from typing import Any
import operator
from typing import Annotated, Any

from pydantic import BaseModel

Expand All@@ -44,10 +45,17 @@


class IncidentState(BaseModel):
"""One state contract for the whole run, however the topology changes."""
"""One state contract for the whole run, however the topology changes.

`notes` is a reducer (`Annotated` + `operator.add`): each writer returns
only its own lines and LangGraph merges them, so a planner that runs two
writers in the same parallel step — including two instances of one kind —
composes instead of colliding. A plain `list[str]` here raises
`InvalidUpdateError` the first time that happens.
"""

goal: str = ""
notes: list[str] = []
notes: Annotated[list[str], operator.add] = []


def _step_factory(spec: NodeSpec) -> Any:
Expand All@@ -59,7 +67,7 @@ def _step_factory(spec: NodeSpec) -> Any:
"""

def body(state: IncidentState) -> dict:
return {"notes": [*state.notes, f"{spec.name} ran"]}
return {"notes": [f"{spec.name} ran"]}

body.writes = {"notes"}
return body
Expand Down
37 changes: 37 additions & 0 deletions tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2198,6 +2198,43 @@ def test_an_init_scaffold_plans_end_to_end(tmp_path, monkeypatch, capsys):
assert "goal_met" in printed


def test_the_scaffold_state_merges_parallel_writers(tmp_path, monkeypatch):
"""Two kinds writing `notes` in the same superstep compose via the reducer.

The shape any real planner eventually proposes: `gather` and `analyse`
both fanned out of START, joining at `report`. With a plain `list[str]`
this run died on LangGraph's InvalidUpdateError before `report` ever ran;
the scaffold's `notes` is a reducer now, and this test is what keeps it
one.
"""
monkeypatch.chdir(tmp_path)
from grapharc.cli.init_cmd import REGISTRY_TEMPLATE
from grapharc.testing import ScriptedChatModel

module = ModuleType("scaffold_registry")
# The path-form loader registers the module before executing it, and
# pydantic needs that to resolve the template's deferred annotations.
monkeypatch.setitem(sys.modules, "scaffold_registry", module)
exec(compile(REGISTRY_TEMPLATE, "registry.py", "exec"), module.__dict__)
plan = json.dumps(
{
"nodes": [{"name": "gather"}, {"name": "analyse"}, {"name": "report"}],
"edges": [
{"source": "__start__", "target": "gather"},
{"source": "__start__", "target": "analyse"},
{"source": "gather", "target": "report"},
{"source": "analyse", "target": "report"},
{"source": "report", "target": "__end__"},
],
}
)
loop = module.build_loop(ScriptedChatModel(responses=[plan]))
result = loop.run("report on this directory, twice over", module.State())
assert result.stop.value == "goal_met"
assert any(note.startswith("gather:") for note in result.state.notes)
assert any(note.startswith("analyse:") for note in result.state.notes)


def test_the_path_form_registry_shares_one_module_object(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "reg.py").write_text(
Expand Down
29 changes: 29 additions & 0 deletions tests/test_planner_loop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1632,3 +1632,32 @@ def test_the_disclosure_is_not_what_refuses_the_edge():
assert [r.model_dump() for r in with_disclosure.rejections()] == [
r.model_dump() for r in without.rejections()
]


def test_the_incident_example_state_merges_parallel_writers():
"""Three kinds writing `notes` in one superstep compose via the reducer.

The shipped example's state used a plain `list[str]`, so the first plan
that fanned kinds out of START died on LangGraph's InvalidUpdateError.
`IncidentState.notes` is a reducer now; this run is the shape that broke.
"""
from grapharc.examples.plan_incident import IncidentState
from grapharc.examples.plan_incident import build_loop as build_incident_loop

fan_out = json.dumps(
{
"nodes": [{"name": "triage"}, {"name": "patch"}, {"name": "verify"}],
"edges": [
{"source": "__start__", "target": "triage"},
{"source": "__start__", "target": "patch"},
{"source": "__start__", "target": "verify"},
{"source": "triage", "target": "__end__"},
{"source": "patch", "target": "__end__"},
{"source": "verify", "target": "__end__"},
],
}
)
loop = build_incident_loop(ScriptedChatModel(responses=[fan_out]))
result = loop.run("triage, patch and verify at once", IncidentState())
assert result.stop.value == "goal_met"
assert sorted(result.state.notes) == ["patch ran", "triage ran", "verify ran"]
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
21 changes: 14 additions & 7 deletions grapharc/cli/init_cmd.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,8 @@

from __future__ import annotations

from typing import Any
import operator
from typing import Annotated, Any

from pydantic import BaseModel

Expand DownExpand Up@@ -87,7 +88,13 @@

class State(BaseModel):
goal: str = "" # filled from the CLI argument; the planner reads it
notes: list[str] = [] # the working record every kind appends to
# `notes` is a REDUCER (Annotated + operator.add): each writer returns just
# its own lines and LangGraph merges them, so two nodes — or two
# planner-named instances of ONE kind — may write it in the same parallel
# step. A plain `list[str]` here crashes the first time a planner runs two
# writers concurrently (InvalidUpdateError); keep the pattern for any field
# more than one node may write.
notes: Annotated[list[str], operator.add] = []
report: str = "" # the deliverable; the goal check below watches it


Expand All@@ -111,7 +118,7 @@ def _gather(state: State) -> dict:
f"({', '.join(dirs[:12]) or 'none'}) and {len(files)} file(s) "
f"({', '.join(files[:12]) or 'none'})"
)
return {"notes": [*state.notes, note]}
return {"notes": [note]}


def _analyse(state: State) -> dict:
Expand All@@ -126,7 +133,7 @@ def _analyse(state: State) -> dict:
note = "analyse: file types by count — " + ", ".join(
f"{ext} x{count}" for ext, count in top
)
return {"notes": [*state.notes, note]}
return {"notes": [note]}


def _report_for(model: Any):
Expand All@@ -142,7 +149,7 @@ def body(state: State) -> dict:
if model is None or scripted or not hasattr(model, "invoke"):
return {
"report": "report: run with --model SPEC for a model-written report",
"notes": [*state.notes, "report: written without a model"],
"notes": ["report: written without a model"],
}
try:
reply = model.invoke(
Expand All@@ -153,7 +160,7 @@ def body(state: State) -> dict:
text = str(getattr(reply, "content", reply)).strip()[:2000]
except Exception as exc: # a failed call is a note, not a crash
text = f"report: model call failed ({exc}); notes stand"
return {"report": text, "notes": [*state.notes, "report: written"]}
return {"report": text, "notes": ["report: written"]}

return body

Expand All@@ -164,7 +171,7 @@ def _apply(state: State) -> dict:
propose it) and DENIED by the edge policy below (no admitted graph may
reach it) until you decide otherwise. Keep the pattern even after you
rename it: a gate with nothing to refuse proves nothing."""
return {"notes": [*state.notes, "apply: this should not have run"]}
return {"notes": ["apply: this should not have run"]}


# ── 3. Write permissions ────────────────────────────────────────────────────
Expand Down
16 changes: 12 additions & 4 deletions grapharc/examples/plan_incident.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,8 @@
from __future__ import annotations

import json
from typing import Any
import operator
from typing import Annotated, Any

from pydantic import BaseModel

Expand All@@ -44,10 +45,17 @@


class IncidentState(BaseModel):
"""One state contract for the whole run, however the topology changes."""
"""One state contract for the whole run, however the topology changes.

`notes` is a reducer (`Annotated` + `operator.add`): each writer returns
only its own lines and LangGraph merges them, so a planner that runs two
writers in the same parallel step — including two instances of one kind —
composes instead of colliding. A plain `list[str]` here raises
`InvalidUpdateError` the first time that happens.
"""

goal: str = ""
notes: list[str] = []
notes: Annotated[list[str], operator.add] = []


def _step_factory(spec: NodeSpec) -> Any:
Expand All@@ -59,7 +67,7 @@ def _step_factory(spec: NodeSpec) -> Any:
"""

def body(state: IncidentState) -> dict:
return {"notes": [*state.notes, f"{spec.name} ran"]}
return {"notes": [f"{spec.name} ran"]}

body.writes = {"notes"}
return body
Expand Down
37 changes: 37 additions & 0 deletions tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2198,6 +2198,43 @@ def test_an_init_scaffold_plans_end_to_end(tmp_path, monkeypatch, capsys):
assert "goal_met" in printed


def test_the_scaffold_state_merges_parallel_writers(tmp_path, monkeypatch):
"""Two kinds writing `notes` in the same superstep compose via the reducer.

The shape any real planner eventually proposes: `gather` and `analyse`
both fanned out of START, joining at `report`. With a plain `list[str]`
this run died on LangGraph's InvalidUpdateError before `report` ever ran;
the scaffold's `notes` is a reducer now, and this test is what keeps it
one.
"""
monkeypatch.chdir(tmp_path)
from grapharc.cli.init_cmd import REGISTRY_TEMPLATE
from grapharc.testing import ScriptedChatModel

module = ModuleType("scaffold_registry")
# The path-form loader registers the module before executing it, and
# pydantic needs that to resolve the template's deferred annotations.
monkeypatch.setitem(sys.modules, "scaffold_registry", module)
exec(compile(REGISTRY_TEMPLATE, "registry.py", "exec"), module.__dict__)
plan = json.dumps(
{
"nodes": [{"name": "gather"}, {"name": "analyse"}, {"name": "report"}],
"edges": [
{"source": "__start__", "target": "gather"},
{"source": "__start__", "target": "analyse"},
{"source": "gather", "target": "report"},
{"source": "analyse", "target": "report"},
{"source": "report", "target": "__end__"},
],
}
)
loop = module.build_loop(ScriptedChatModel(responses=[plan]))
result = loop.run("report on this directory, twice over", module.State())
assert result.stop.value == "goal_met"
assert any(note.startswith("gather:") for note in result.state.notes)
assert any(note.startswith("analyse:") for note in result.state.notes)


def test_the_path_form_registry_shares_one_module_object(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "reg.py").write_text(
Expand Down
29 changes: 29 additions & 0 deletions tests/test_planner_loop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1632,3 +1632,32 @@ def test_the_disclosure_is_not_what_refuses_the_edge():
assert [r.model_dump() for r in with_disclosure.rejections()] == [
r.model_dump() for r in without.rejections()
]


def test_the_incident_example_state_merges_parallel_writers():
"""Three kinds writing `notes` in one superstep compose via the reducer.

The shipped example's state used a plain `list[str]`, so the first plan
that fanned kinds out of START died on LangGraph's InvalidUpdateError.
`IncidentState.notes` is a reducer now; this run is the shape that broke.
"""
from grapharc.examples.plan_incident import IncidentState
from grapharc.examples.plan_incident import build_loop as build_incident_loop

fan_out = json.dumps(
{
"nodes": [{"name": "triage"}, {"name": "patch"}, {"name": "verify"}],
"edges": [
{"source": "__start__", "target": "triage"},
{"source": "__start__", "target": "patch"},
{"source": "__start__", "target": "verify"},
{"source": "triage", "target": "__end__"},
{"source": "patch", "target": "__end__"},
{"source": "verify", "target": "__end__"},
],
}
)
loop = build_incident_loop(ScriptedChatModel(responses=[fan_out]))
result = loop.run("triage, patch and verify at once", IncidentState())
assert result.stop.value == "goal_met"
assert sorted(result.state.notes) == ["patch ran", "triage ran", "verify ran"]
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
21 changes: 14 additions & 7 deletions grapharc/cli/init_cmd.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,8 @@

from __future__ import annotations

from typing import Any
import operator
from typing import Annotated, Any

from pydantic import BaseModel

Expand DownExpand Up@@ -87,7 +88,13 @@

class State(BaseModel):
goal: str = "" # filled from the CLI argument; the planner reads it
notes: list[str] = [] # the working record every kind appends to
# `notes` is a REDUCER (Annotated + operator.add): each writer returns just
# its own lines and LangGraph merges them, so two nodes — or two
# planner-named instances of ONE kind — may write it in the same parallel
# step. A plain `list[str]` here crashes the first time a planner runs two
# writers concurrently (InvalidUpdateError); keep the pattern for any field
# more than one node may write.
notes: Annotated[list[str], operator.add] = []
report: str = "" # the deliverable; the goal check below watches it


Expand All@@ -111,7 +118,7 @@ def _gather(state: State) -> dict:
f"({', '.join(dirs[:12]) or 'none'}) and {len(files)} file(s) "
f"({', '.join(files[:12]) or 'none'})"
)
return {"notes": [*state.notes, note]}
return {"notes": [note]}


def _analyse(state: State) -> dict:
Expand All@@ -126,7 +133,7 @@ def _analyse(state: State) -> dict:
note = "analyse: file types by count — " + ", ".join(
f"{ext} x{count}" for ext, count in top
)
return {"notes": [*state.notes, note]}
return {"notes": [note]}


def _report_for(model: Any):
Expand All@@ -142,7 +149,7 @@ def body(state: State) -> dict:
if model is None or scripted or not hasattr(model, "invoke"):
return {
"report": "report: run with --model SPEC for a model-written report",
"notes": [*state.notes, "report: written without a model"],
"notes": ["report: written without a model"],
}
try:
reply = model.invoke(
Expand All@@ -153,7 +160,7 @@ def body(state: State) -> dict:
text = str(getattr(reply, "content", reply)).strip()[:2000]
except Exception as exc: # a failed call is a note, not a crash
text = f"report: model call failed ({exc}); notes stand"
return {"report": text, "notes": [*state.notes, "report: written"]}
return {"report": text, "notes": ["report: written"]}

return body

Expand All@@ -164,7 +171,7 @@ def _apply(state: State) -> dict:
propose it) and DENIED by the edge policy below (no admitted graph may
reach it) until you decide otherwise. Keep the pattern even after you
rename it: a gate with nothing to refuse proves nothing."""
return {"notes": [*state.notes, "apply: this should not have run"]}
return {"notes": ["apply: this should not have run"]}


# ── 3. Write permissions ────────────────────────────────────────────────────
Expand Down
16 changes: 12 additions & 4 deletions grapharc/examples/plan_incident.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,8 @@
from __future__ import annotations

import json
from typing import Any
import operator
from typing import Annotated, Any

from pydantic import BaseModel

Expand All@@ -44,10 +45,17 @@


class IncidentState(BaseModel):
"""One state contract for the whole run, however the topology changes."""
"""One state contract for the whole run, however the topology changes.

`notes` is a reducer (`Annotated` + `operator.add`): each writer returns
only its own lines and LangGraph merges them, so a planner that runs two
writers in the same parallel step — including two instances of one kind —
composes instead of colliding. A plain `list[str]` here raises
`InvalidUpdateError` the first time that happens.
"""

goal: str = ""
notes: list[str] = []
notes: Annotated[list[str], operator.add] = []


def _step_factory(spec: NodeSpec) -> Any:
Expand All@@ -59,7 +67,7 @@ def _step_factory(spec: NodeSpec) -> Any:
"""

def body(state: IncidentState) -> dict:
return {"notes": [*state.notes, f"{spec.name} ran"]}
return {"notes": [f"{spec.name} ran"]}

body.writes = {"notes"}
return body
Expand Down
37 changes: 37 additions & 0 deletions tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2198,6 +2198,43 @@ def test_an_init_scaffold_plans_end_to_end(tmp_path, monkeypatch, capsys):
assert "goal_met" in printed


def test_the_scaffold_state_merges_parallel_writers(tmp_path, monkeypatch):
"""Two kinds writing `notes` in the same superstep compose via the reducer.

The shape any real planner eventually proposes: `gather` and `analyse`
both fanned out of START, joining at `report`. With a plain `list[str]`
this run died on LangGraph's InvalidUpdateError before `report` ever ran;
the scaffold's `notes` is a reducer now, and this test is what keeps it
one.
"""
monkeypatch.chdir(tmp_path)
from grapharc.cli.init_cmd import REGISTRY_TEMPLATE
from grapharc.testing import ScriptedChatModel

module = ModuleType("scaffold_registry")
# The path-form loader registers the module before executing it, and
# pydantic needs that to resolve the template's deferred annotations.
monkeypatch.setitem(sys.modules, "scaffold_registry", module)
exec(compile(REGISTRY_TEMPLATE, "registry.py", "exec"), module.__dict__)
plan = json.dumps(
{
"nodes": [{"name": "gather"}, {"name": "analyse"}, {"name": "report"}],
"edges": [
{"source": "__start__", "target": "gather"},
{"source": "__start__", "target": "analyse"},
{"source": "gather", "target": "report"},
{"source": "analyse", "target": "report"},
{"source": "report", "target": "__end__"},
],
}
)
loop = module.build_loop(ScriptedChatModel(responses=[plan]))
result = loop.run("report on this directory, twice over", module.State())
assert result.stop.value == "goal_met"
assert any(note.startswith("gather:") for note in result.state.notes)
assert any(note.startswith("analyse:") for note in result.state.notes)


def test_the_path_form_registry_shares_one_module_object(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "reg.py").write_text(
Expand Down
29 changes: 29 additions & 0 deletions tests/test_planner_loop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1632,3 +1632,32 @@ def test_the_disclosure_is_not_what_refuses_the_edge():
assert [r.model_dump() for r in with_disclosure.rejections()] == [
r.model_dump() for r in without.rejections()
]


def test_the_incident_example_state_merges_parallel_writers():
"""Three kinds writing `notes` in one superstep compose via the reducer.

The shipped example's state used a plain `list[str]`, so the first plan
that fanned kinds out of START died on LangGraph's InvalidUpdateError.
`IncidentState.notes` is a reducer now; this run is the shape that broke.
"""
from grapharc.examples.plan_incident import IncidentState
from grapharc.examples.plan_incident import build_loop as build_incident_loop

fan_out = json.dumps(
{
"nodes": [{"name": "triage"}, {"name": "patch"}, {"name": "verify"}],
"edges": [
{"source": "__start__", "target": "triage"},
{"source": "__start__", "target": "patch"},
{"source": "__start__", "target": "verify"},
{"source": "triage", "target": "__end__"},
{"source": "patch", "target": "__end__"},
{"source": "verify", "target": "__end__"},
],
}
)
loop = build_incident_loop(ScriptedChatModel(responses=[fan_out]))
result = loop.run("triage, patch and verify at once", IncidentState())
assert result.stop.value == "goal_met"
assert sorted(result.state.notes) == ["patch ran", "triage ran", "verify ran"]
Loading