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
8 changes: 7 additions & 1 deletion src/broadside_ai/backends/ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ async def complete(self, prompt: str, **kwargs: Any) -> AgentResult:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(f"{self.base_url}/api/generate", json=payload)
response.raise_for_status()
data = response.json()
try:
data = response.json()
except ValueError:
raise RuntimeError(
f"Invalid response from Ollama at {self.base_url}. "
"The server may be overloaded or returning an error page."
) from None
except httpx.ConnectError:
raise ConnectionError(
f"Can't connect to Ollama at {self.base_url}.\n\n"
Expand Down
5 changes: 4 additions & 1 deletion src/broadside_ai/backends/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,15 @@ async def complete(self, prompt: str, **kwargs: Any) -> AgentResult:
model=kwargs.pop("model", self.model),
max_tokens=kwargs.pop("max_tokens", self.max_tokens),
messages=[{"role": "user", "content": prompt}],
stream=False,
**kwargs,
)

latency = (time.perf_counter() - t0) * 1000
choice = response.choices[0] if response.choices else None
text = choice.message.content or "" if choice else ""
text = ""
if choice and choice.message:
text = choice.message.content or ""
usage = response.usage
tokens_in = usage.prompt_tokens if usage else 0
tokens_out = usage.completion_tokens if usage else 0
Expand Down
2 changes: 1 addition & 1 deletion src/broadside_ai/budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def record(self, tokens: int) -> None:
"""Record tokens consumed by a branch. Raises if budget exceeded."""
with self._lock:
self._used += tokens
if self.max_tokens is not None and self._used > self.max_tokens:
if self.max_tokens is not None and self._used >= self.max_tokens:
raise BudgetExceeded(self.max_tokens, self._used)

@property
Expand Down
2 changes: 1 addition & 1 deletion src/broadside_ai/conflicts.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ async def detect_conflicts(
This is a separate step from synthesis - you can run it independently
to audit outputs before deciding how to synthesize.
"""
bk = backend_kwargs or {}
bk = dict(backend_kwargs or {})
if model:
bk["model"] = model
llm = get_backend(backend, **bk)
Expand Down
27 changes: 20 additions & 7 deletions src/broadside_ai/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,24 @@ def _try_extract_object(text: str) -> dict[str, Any] | None:
return None

depth = 0
for index in range(start, len(text)):
if text[index] == "{":
depth += 1
elif text[index] == "}":
depth -= 1
if depth == 0:
return _try_loads(text[start : index + 1])
in_string = False
i = start
while i < len(text):
ch = text[i]
if in_string:
if ch == "\\":
i += 2
continue
if ch == '"':
in_string = False
else:
if ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return _try_loads(text[start : i + 1])
i += 1
return None
2 changes: 1 addition & 1 deletion src/broadside_ai/strategies/consensus.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ async def synthesize_consensus(
2. Where agents disagreed and what each side said
3. Any facts that only one agent mentioned
"""
bk = backend_kwargs or {}
bk = dict(backend_kwargs or {})
if model:
bk["model"] = model
llm = get_backend(backend, **bk)
Expand Down
2 changes: 1 addition & 1 deletion src/broadside_ai/strategies/voting.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ async def synthesize_voting(
2. extract_labels=False: Treat each full output as a "vote" and
use the LLM to identify the majority position.
"""
bk = backend_kwargs or {}
bk = dict(backend_kwargs or {})
if model:
bk["model"] = model
llm = get_backend(backend, **bk)
Expand Down
7 changes: 5 additions & 2 deletions src/broadside_ai/task_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,12 @@ def validate_task_file(path: str) -> list[str]:
if not isinstance(recommended_n, int) or recommended_n < 1:
errors.append("meta.recommended_n must be a positive integer")
elif recommended_n > 10:
errors.append(
import warnings

warnings.warn(
f"meta.recommended_n is {recommended_n}. "
"Performance plateaus at 3-5. Are you sure?"
"Performance typically plateaus at 3-5.",
stacklevel=2,
)

return errors
Expand Down
8 changes: 8 additions & 0 deletions tests/test_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,11 @@ def test_budget_exhausted_flag():
# Manually push past limit to test exhausted check
b._used = 1000
assert b.exhausted


def test_budget_exact_boundary_raises():
"""record() and exhausted must agree at the exact boundary."""
b = ScatterBudget(max_tokens=1000)
with pytest.raises(BudgetExceeded):
b.record(1000)
assert b.exhausted
18 changes: 17 additions & 1 deletion tests/test_gather.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for gather result normalization."""
"""Tests for gather result normalization and JSON parsing."""

from broadside_ai.gather import gather
from broadside_ai.parsing import try_parse_json
from tests.conftest import make_result


Expand Down Expand Up @@ -35,3 +36,18 @@ def test_gather_summary():
assert summary["n_requested"] == 1
assert summary["completed"] == 1
assert summary["n_parsed"] == 0


def test_parse_json_with_braces_in_string_values():
text = '{"msg": "use {} for templates", "count": 1}'
result = try_parse_json(text)
assert result is not None
assert result["msg"] == "use {} for templates"
assert result["count"] == 1


def test_parse_json_embedded_with_braces_in_strings():
text = 'Here is the result: {"msg": "pattern {x} works", "ok": true} done.'
result = try_parse_json(text)
assert result is not None
assert result["ok"] is True
14 changes: 14 additions & 0 deletions tests/test_synthesize.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,17 @@ async def test_consensus_strategy_keeps_analysis_oriented_prompt():
assert "extract the CONSENSUS" in RecordingBackend.last_prompt
assert "DISAGREEMENTS:" in RecordingBackend.last_prompt
assert "UNIQUE CLAIMS:" in RecordingBackend.last_prompt


async def test_consensus_does_not_mutate_backend_kwargs():
gathered = make_gather(["answer A", "answer B"])
kwargs: dict[str, object] = {"timeout": 30}
await synthesize(gathered, strategy="consensus", backend="recording", backend_kwargs=kwargs)
assert "model" not in kwargs


async def test_voting_does_not_mutate_backend_kwargs():
gathered = make_gather(["answer A", "answer B"])
kwargs: dict[str, object] = {"timeout": 30}
await synthesize(gathered, strategy="voting", backend="recording", backend_kwargs=kwargs)
assert "model" not in kwargs
Loading