Skip to content

Commit ded64c0

Browse files
committed
Merge branch 'feat/agentos-control-plane' into feat/agentos-deploy
2 parents c2d7b79 + 4467f7e commit ded64c0

5 files changed

Lines changed: 68 additions & 16 deletions

File tree

src/hive/evals/evaluators.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,26 @@
1111
if TYPE_CHECKING:
1212
from hive.models.base import BaseProvider
1313

14-
_JUDGE_PROMPT = """You are grading an AI assistant's answer.
14+
_JUDGE_RUBRIC = (
15+
"Rate how well the actual answer satisfies the expected answer on a scale of 0 to "
16+
"10 (10 = fully correct and complete, 0 = wrong or missing). Reply with ONLY the "
17+
"number on the first line, then a one-sentence justification."
18+
)
1519

16-
Task given to the assistant:
17-
{instruction}
1820

19-
Expected answer (reference):
20-
{expected}
21+
def _build_judge_prompt(instruction: str, expected: str, actual: str) -> str:
22+
"""Build the judge prompt by concatenation.
2123
22-
Assistant's actual answer:
23-
{actual}
24-
25-
Rate how well the actual answer satisfies the expected answer on a scale of 0 to 10
26-
(10 = fully correct and complete, 0 = wrong or missing). Reply with ONLY the number
27-
on the first line, then a one-sentence justification."""
24+
Avoids ``str.format``/f-string substitution so brace characters in the instruction,
25+
expected answer, or actual output (e.g. JSON like ``{"k": 1}``) can't raise.
26+
"""
27+
return (
28+
"You are grading an AI assistant's answer.\n\n"
29+
f"Task given to the assistant:\n{instruction}\n\n"
30+
f"Expected answer (reference):\n{expected}\n\n"
31+
f"Assistant's actual answer:\n{actual}\n\n"
32+
f"{_JUDGE_RUBRIC}"
33+
)
2834

2935

3036
class AccuracyEval:
@@ -41,9 +47,7 @@ async def evaluate(self, run: EvalRun, case: EvalCase) -> CaseResult:
4147
return CaseResult(
4248
case, self.name, True, 1.0, "no expected_answer (skipped)", run, skipped=True
4349
)
44-
prompt = _JUDGE_PROMPT.format(
45-
instruction=case.instruction, expected=case.expected_answer, actual=run.output
46-
)
50+
prompt = _build_judge_prompt(case.instruction, case.expected_answer, run.output)
4751
result = await self._judge.generate_with_metadata(messages=[Message.user(prompt)])
4852
score, detail = self._parse(result.message.content)
4953
return CaseResult(case, self.name, score >= self._threshold, score, detail, run)

src/hive/evals/runner.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,16 @@ def __init__(self, agent: Agent):
2929
async def run(self, case: EvalCase) -> EvalRun:
3030
async with self._lock:
3131
calls: list[str] = []
32+
# Snapshot and restore any pre-existing observer instead of clearing it,
33+
# so an agent built with Agent(on_tool=...) keeps its callback afterward.
34+
previous_on_tool = self._agent._on_tool
3235
self._agent.observe_tools(lambda name, args, ok: calls.append(name))
3336
try:
3437
result = await self._agent.run(
3538
Task(instruction=case.instruction, context=case.context)
3639
)
3740
finally:
38-
self._agent.observe_tools(None)
41+
self._agent.observe_tools(previous_on_tool)
3942
return EvalRun(
4043
instruction=case.instruction,
4144
output=result.output,

src/hive/runtime/agent.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,13 @@ async def _run_loop(self, task: Task) -> TaskResult:
598598
output = "[output withheld by guardrail]"
599599
elif finding.action is GuardrailAction.REDACT:
600600
output = finding.text
601-
self._write_conversation_log(task.id, conversation.get_messages(), "completed")
601+
# The raw assistant message is already in the conversation; replace it
602+
# with the sanitized output for the on-disk log too, so a redacting
603+
# guardrail doesn't leak the unredacted content into the JSON log file.
604+
log_messages = conversation.get_messages()
605+
if output != response.content:
606+
log_messages = [*log_messages[:-1], Message.assistant(output)]
607+
self._write_conversation_log(task.id, log_messages, "completed")
602608
return TaskResult(
603609
task_id=task.id,
604610
status=TaskStatus.COMPLETED,

tests/evals/test_evals.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,14 @@ async def test_no_expected_answer_skips(self) -> None:
128128
res = await AccuracyEval(judge=judge).evaluate(_run("x"), EvalCase("q"))
129129
assert res.passed and res.skipped and "skipped" in res.detail
130130

131+
@pytest.mark.asyncio
132+
async def test_braces_in_content_do_not_crash_judge(self) -> None:
133+
judge = ScriptedProvider([Message.assistant("8\nok")])
134+
run = _run('{"key": "value", "n": {}}') # braces in the actual output
135+
case = EvalCase("Parse JSON: {}", expected_answer="{'k': 1}")
136+
res = await AccuracyEval(judge=judge).evaluate(run, case)
137+
assert res.score == pytest.approx(0.8)
138+
131139

132140
class TestReportMetrics:
133141
def test_skipped_cases_excluded_from_rates(self) -> None:
@@ -153,6 +161,19 @@ def test_duplicate_evaluator_names_rejected(self) -> None:
153161
with pytest.raises(ValueError, match="duplicate evaluator"):
154162
EvalSuite(AgentEvalRunner(agent), [ReliabilityEval(), ReliabilityEval()])
155163

164+
@pytest.mark.asyncio
165+
async def test_runner_restores_pre_existing_observer(self) -> None:
166+
seen: list[str] = []
167+
agent = Agent(
168+
name="solver",
169+
model=ScriptedProvider([Message.assistant("done")]), # type: ignore[arg-type]
170+
on_tool=lambda n, a, ok: seen.append(n),
171+
)
172+
await AgentEvalRunner(agent).run(EvalCase("hi"))
173+
# The caller's original observer is restored, not dropped.
174+
assert agent._on_tool is not None
175+
assert agent._on_tool(("x"), {}, True) is None and seen == ["x"]
176+
156177

157178
class TestSuiteIntegration:
158179
@pytest.mark.asyncio

tests/runtime/test_guardrails.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from pathlib import Path
56
from typing import Any
67

78
import pytest
@@ -126,6 +127,23 @@ async def test_output_pii_is_redacted(self) -> None:
126127
assert "leak@corp.com" not in result.output
127128
assert "REDACTED" in result.output
128129

130+
@pytest.mark.asyncio
131+
async def test_redacted_output_not_leaked_to_conversation_log(self, tmp_path: Path) -> None:
132+
from hive.runtime.types import Task
133+
134+
agent = Agent(
135+
name="a",
136+
model=MockProvider("the email is leak@corp.com"), # type: ignore[arg-type]
137+
guardrails=build_guardrail_pipeline(GuardrailConfig(enabled=True)),
138+
conversation_log_dir=tmp_path,
139+
)
140+
await agent.run(Task(instruction="contact?"))
141+
logged = "\n".join(p.read_text() for p in tmp_path.rglob("*.json"))
142+
# The on-disk conversation log must not contain the unredacted PII.
143+
assert logged # a log was written
144+
assert "leak@corp.com" not in logged
145+
assert "REDACTED" in logged
146+
129147
@pytest.mark.asyncio
130148
async def test_disabled_guardrails_passthrough(self) -> None:
131149
from hive.runtime.types import Task

0 commit comments

Comments
 (0)