Add ask_expert and eval capture (A14/A16) — step 7c
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m7s
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m7s
Completes step 7. - expert.py: one stateless call for a question needing no repo access. Structured output is contracted at the prompt level, since vendors differ on JSON enforcement, so the reply is parsed and validated locally with one retry — tolerating a fenced code block, because models wrap JSON habitually, but not missing fields - The per-task budget is counted from the canonical log rather than memory, so it survives a restart and cannot hand a circling executor a fresh allowance. Exhaustion raises rather than answering: a caller out of consults should get on with the work or escalate - Consults are recorded as delegation_started/completed with role consultant. A16 asks for a "consult/delegation eval set", and reusing the composite events gets consult evaluation for free rather than inventing a parallel record - evals.py pairs starts with completions into eval records, with verdicts kept outside the canonical log: the log records what happened, a verdict is a later judgement about it, and judgements get revised. Schema v3 adds the outcomes table, so a v1 store now traverses two migrations — a better exercise of the hook than one - ask_expert is offered to the executor as a tool only when a desk is configured, in addition to the observable escalation triggers and never instead of them (A12). Consulting is not mutating, so a completion claim backed only by consults is still challenged Found live: passing an empty message list and putting the question in the system prompt fails outright — the Messages API requires at least one non-system message. The question now travels as a synthesised user message that is never appended to the log, which keeps the consult stateless while remaining a valid request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dd22eda528
commit
2b7651fb37
9 files changed
+846
-8
No files matched your search
@@ -223,3 +223,95 @@ def test_multiple_calls_in_one_turn_all_run(store: StateStore, ws: Workspace) ->
|
||||
e.event for e in store.read_events() if type(e.event).__name__ == "ToolResult"
|
||||
]
|
||||
assert {r.call_id for r in results} == {"a", "b"}
|
||||
|
||||
|
||||
class StubDesk:
|
||||
"""Stands in for ExpertDesk without a model behind it."""
|
||||
|
||||
def __init__(self, answer: str = "use sqlite", error: Exception | None = None):
|
||||
self.asked: list[str] = []
|
||||
self._answer = answer
|
||||
self._error = error
|
||||
|
||||
def ask(self, question: str, **kw: Any) -> Any:
|
||||
self.asked.append(question)
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
|
||||
class _A:
|
||||
def render(self_inner) -> str:
|
||||
return self._answer
|
||||
|
||||
return _A()
|
||||
|
||||
|
||||
def test_ask_expert_is_offered_only_when_a_desk_exists(
|
||||
store: StateStore, ws: Workspace
|
||||
) -> None:
|
||||
client = ScriptedClient([])
|
||||
build(client, store, ws).run(milestone=1)
|
||||
assert all(t["function"]["name"] != "ask_expert" for t in client.calls[0]["tools"])
|
||||
|
||||
client2 = ScriptedClient([])
|
||||
build(client2, store, ws, expert=StubDesk()).run(milestone=1)
|
||||
names = [t["function"]["name"] for t in client2.calls[0]["tools"]]
|
||||
assert names[-1] == "ask_expert"
|
||||
# A12: in addition to the observable triggers, not instead of them.
|
||||
assert "run_command" in names
|
||||
|
||||
|
||||
def test_ask_expert_answer_comes_back_as_a_tool_result(
|
||||
store: StateStore, ws: Workspace
|
||||
) -> None:
|
||||
desk = StubDesk(answer="prefer sqlite here")
|
||||
client = ScriptedClient(
|
||||
[
|
||||
[call("ask_expert", question="sqlite or postgres?")],
|
||||
[call("write_file", path="a.py", content="x")],
|
||||
[AssistantText(text="done", model="m")],
|
||||
]
|
||||
)
|
||||
build(client, store, ws, expert=desk).run(milestone=1)
|
||||
|
||||
assert desk.asked == ["sqlite or postgres?"]
|
||||
results = [
|
||||
e.event for e in store.read_events() if type(e.event).__name__ == "ToolResult"
|
||||
]
|
||||
assert "prefer sqlite here" in results[0].result
|
||||
|
||||
|
||||
def test_consulting_is_not_doing_the_work(store: StateStore, ws: Workspace) -> None:
|
||||
# Asking is not changing anything, so a completion claim backed only by
|
||||
# consults is still challenged.
|
||||
client = ScriptedClient(
|
||||
[
|
||||
[call("ask_expert", question="what should I do?")],
|
||||
[AssistantText(text="I think it is done", model="m")],
|
||||
[AssistantText(text="really done", model="m")],
|
||||
]
|
||||
)
|
||||
outcome = build(client, store, ws, expert=StubDesk()).run(milestone=1)
|
||||
assert outcome.status == "escalate"
|
||||
|
||||
|
||||
def test_exhausted_budget_is_reported_to_the_model_not_raised(
|
||||
store: StateStore, ws: Workspace
|
||||
) -> None:
|
||||
from nisse_dev.expert import BudgetExhausted
|
||||
|
||||
desk = StubDesk(error=BudgetExhausted("consult budget of 3 is spent"))
|
||||
client = ScriptedClient(
|
||||
[
|
||||
[call("ask_expert", question="again?")],
|
||||
[call("write_file", path="a.py", content="x")],
|
||||
[AssistantText(text="done", model="m")],
|
||||
]
|
||||
)
|
||||
outcome = build(client, store, ws, expert=desk).run(milestone=1)
|
||||
|
||||
assert outcome.status == "completed" # it got on with the work
|
||||
results = [
|
||||
e.event for e in store.read_events() if type(e.event).__name__ == "ToolResult"
|
||||
]
|
||||
assert not results[0].success
|
||||
assert "budget" in results[0].result
|
||||
Reference in new issue
Block a user