Skip to content

Commit 86cda64

Browse files
adv0rcursoragent
andauthored
agent: validate goal input in StandardAgent.solve() (#130)
Closes #76 Before this change, passing `None`, a non-string, or an empty/whitespace string to `solve()` resulted in a confusing traceback from deep inside either the goal preprocessor (`process(None, ...)`) or the reasoner. The state machine could even be left in `BUSY` because validation happened *after* `_state = AgentState.BUSY`. This PR validates `goal` at the very top of `solve()`: - `goal is None` → `TypeError("goal must be a non-empty str, got None")` - `not isinstance(str)` → `TypeError("goal must be a str, got <type>")` (informative type name in the message) - `not goal.strip()` → `ValueError("goal must be a non-empty string ...")` Whitespace-bearing goals like `" do the thing "` are still accepted — we only reject *empty* or *only-whitespace* inputs. Why these specific exceptions: - `TypeError` for None / wrong type, matching the Python stdlib convention for "you passed an object of the wrong shape". - `ValueError` for empty/whitespace, matching the convention for "right type, wrong value". State machine invariant: validation happens *before* any state mutation, so a rejected `solve(...)` leaves `agent.state == READY` (asserted in every new test). Tests (`tests/agents/test_standard_agent.py`): - `test_solve_raises_type_error_on_none_goal` - `test_solve_raises_type_error_on_non_string_goal` — parametrized over `[123, 1.5, ["a"], {"x": 1}, b"bytes"]` - `test_solve_raises_value_error_on_empty_or_whitespace_goal` — parametrized over `["", " ", "\n", "\t \n"]` - `test_solve_accepts_goal_with_leading_or_trailing_whitespace` — guards against over-strict validation regression. All 28 tests in `tests/agents/test_standard_agent.py` pass (17 existing + 11 new). `ruff check` clean on touched files. Pre-existing mypy issues are unchanged (verified: same count on `main` vs this branch). AI-assisted via Cursor (Claude Opus 4.7). Personal token-burn initiative by @adv0r to use up an expiring Cursor subscription budget on small, useful upstream contributions. Co-authored-by: adv0r <> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c375c6c commit 86cda64

2 files changed

Lines changed: 65 additions & 1 deletion

File tree

agents/standard_agent.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,28 @@ def state(self) -> AgentState:
8585

8686
@observe(root=True)
8787
def solve(self, goal: str) -> ReasoningResult:
88-
"""Solves a goal synchronously (library-style API)."""
88+
"""Solves a goal synchronously (library-style API).
89+
90+
Args:
91+
goal: A non-empty string describing what to solve.
92+
93+
Raises:
94+
TypeError: ``goal`` is not a ``str`` (or is ``None``).
95+
ValueError: ``goal`` is empty or contains only whitespace.
96+
"""
97+
# Validate the goal up-front so callers get a clear, immediate error
98+
# instead of a confusing traceback from deep inside the reasoner or
99+
# the goal preprocessor (which can blow up on None / non-string
100+
# inputs in non-obvious ways).
101+
if goal is None:
102+
raise TypeError("goal must be a non-empty str, got None")
103+
if not isinstance(goal, str):
104+
raise TypeError(
105+
f"goal must be a str, got {type(goal).__name__}"
106+
)
107+
if not goal.strip():
108+
raise ValueError("goal must be a non-empty string (got empty or whitespace-only)")
109+
89110
run_id = uuid4().hex
90111
start_time = time.perf_counter()
91112

tests/agents/test_standard_agent.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,49 @@ def test_agent_solve_sets_final_answer_from_summarizer_and_records_history(monke
101101
assert hist[-1]["result"] == "SUMMARIZED"
102102

103103

104+
def _make_validation_agent():
105+
"""Helper: build a StandardAgent good enough to call solve() on."""
106+
llm = DummyLLM(text_queue=["SUMMARIZED"])
107+
tools = DummyTools()
108+
memory: Dict[str, Any] = DictMemory()
109+
reasoner = DummyReasoner()
110+
return StandardAgent(llm=llm, tools=tools, memory=memory, reasoner=reasoner)
111+
112+
113+
def test_solve_raises_type_error_on_none_goal():
114+
"""solve(None) -> TypeError, agent never reaches the reasoner."""
115+
agent = _make_validation_agent()
116+
with pytest.raises(TypeError, match="goal must be"):
117+
agent.solve(None) # type: ignore[arg-type]
118+
# State must NOT flip to BUSY/NEEDS_ATTENTION on input validation.
119+
assert agent.state == AgentState.READY
120+
121+
122+
@pytest.mark.parametrize("bad_goal", [123, 1.5, ["a"], {"x": 1}, b"bytes"])
123+
def test_solve_raises_type_error_on_non_string_goal(bad_goal):
124+
"""solve(<non-str>) -> TypeError with type name in the message."""
125+
agent = _make_validation_agent()
126+
with pytest.raises(TypeError, match=r"goal must be a str, got "):
127+
agent.solve(bad_goal) # type: ignore[arg-type]
128+
assert agent.state == AgentState.READY
129+
130+
131+
@pytest.mark.parametrize("empty", ["", " ", "\n", "\t \n"])
132+
def test_solve_raises_value_error_on_empty_or_whitespace_goal(empty):
133+
"""solve('' or whitespace-only) -> ValueError."""
134+
agent = _make_validation_agent()
135+
with pytest.raises(ValueError, match="non-empty"):
136+
agent.solve(empty)
137+
assert agent.state == AgentState.READY
138+
139+
140+
def test_solve_accepts_goal_with_leading_or_trailing_whitespace():
141+
"""Leading/trailing whitespace is allowed as long as content exists."""
142+
agent = _make_validation_agent()
143+
result = agent.solve(" do the thing ")
144+
assert result.final_answer == "SUMMARIZED"
145+
146+
104147
def test_agent_uses_goal_preprocessor_and_returns_intervention_message(monkeypatch):
105148
_fixed_uuid4(monkeypatch, "RUNINT")
106149
llm = DummyLLM(text_queue=["UNUSED"])

0 commit comments

Comments
 (0)