Repository navigation
feat(arms): multi-turn session measurement with per-turn usage #174
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c2ccd81
41fc167
5ddf3c5
1a20b18
c13a75f
00204bf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| <!-- | ||
| Copyright (C) 2026 Intel Corporation | ||
| SPDX-License-Identifier: Apache-2.0 | ||
| --> | ||
|
|
||
| ### Added | ||
|
|
||
| - **Multi-turn session measurement for `arms run`** — `--session-turns N` asks each question as its own text followed by the first N-1 entries of its `follow_ups` list, keeps the conversation history across the turns, and records each turn's input, output, cache read and write tokens and cost under `session.turns` of the arm. The arm's `token_usage` and `metrics` are the sums over turns, so cost rollups read them as before. `--turn-reminder TEXT` also runs each arm as `<arm>+reminder`, with the text appended to every user turn in a `<system-reminder>` block, to test whether a per-turn reminder keeps an instruction in force in later turns. Measurement only: `--judge` is refused with `--session-turns`, a question with too few `follow_ups` fails the run before any answer is paid for, and agentic arms (`mcp:`, `skill-agent:`, `skill-catalog:`) are refused per arm. Without the flags, `arms run` behaves as before. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,7 @@ | |
| from dokimi.eval.judge_context import build as build_judge_context | ||
| from dokimi.eval.judge_context import cite as cite_judge_materials | ||
| from dokimi.eval.judge_context import join_injected | ||
| from dokimi.eval.session import REMINDER_ARM_SUFFIX, run_session, session_turns_of | ||
| from dokimi.metrics.usage import to_record | ||
| from dokimi.treatments.base import Treatment | ||
|
|
||
|
|
@@ -109,6 +110,16 @@ def _static_arm_kind(treatment: Treatment) -> Dict[str, Any]: | |
| return {"arm_kind": getattr(t, "arm_kind", None)} | ||
|
|
||
|
|
||
|
|
||
| def _refuse_session_records(recs: Sequence[Record]) -> None: | ||
| """Refuse to judge a session run: its last answer would be scored against its first question.""" | ||
| if any(r.raw.get("session_turns") for r in recs): | ||
| raise ValueError( | ||
| "Session records are measurement-only: the judge would score a session's last " | ||
| "answer against its first question. Judge a single-turn run instead." | ||
| ) | ||
|
|
||
|
|
||
| class ArmRunner: | ||
| """Generate answers for each treatment arm and (optionally) judge them.""" | ||
|
|
||
|
|
@@ -133,9 +144,17 @@ def __init__( | |
| stats_correction: str = "holm", | ||
| temperature: Optional[float] = None, | ||
| seed: Optional[int] = None, | ||
| session_turns: Optional[int] = None, | ||
| turn_reminder: Optional[str] = None, | ||
| ): | ||
| if not treatments: | ||
| raise ValueError("ArmRunner requires at least one treatment arm") | ||
| if session_turns is not None and session_turns < 2: | ||
| raise ValueError( | ||
| f"session_turns must be at least 2 (one turn is the plain run), got {session_turns}" | ||
| ) | ||
| if turn_reminder is not None and (session_turns is None or not turn_reminder.strip()): | ||
| raise ValueError("turn_reminder needs session_turns and non-blank text") | ||
| # Rejected here rather than at summarize time: the correction is only | ||
| # applied after every arm has been answered and judged, so a typo would | ||
| # otherwise surface after the whole run has been paid for. | ||
|
|
@@ -153,6 +172,8 @@ def __init__( | |
| self.max_tokens = max_tokens | ||
| self.temperature = temperature | ||
| self.seed = seed | ||
| self.session_turns = session_turns | ||
| self.turn_reminder = turn_reminder | ||
| self.grounded_from_topics = grounded_from_topics | ||
| self.harness = harness | ||
| self.matrix_cell = matrix_cell | ||
|
|
@@ -168,7 +189,10 @@ def __init__( | |
|
|
||
| @property | ||
| def arm_names(self) -> List[str]: | ||
| return [t.name for t in self.treatments] | ||
| names = [t.name for t in self.treatments] | ||
| if self.turn_reminder is not None: | ||
| names += [name + REMINDER_ARM_SUFFIX for name in names] | ||
| return names | ||
|
|
||
| def _run_config(self) -> Dict[str, Any]: | ||
| """Request-shaping settings, recorded so a run stays interpretable later. | ||
|
|
@@ -183,13 +207,19 @@ def _run_config(self) -> Dict[str, Any]: | |
| non-deterministic by default, so a pinned run and a free-sampling one are | ||
| differently reproducible and should not be silently pooled as replicates. | ||
| """ | ||
| return { | ||
| config: Dict[str, Any] = { | ||
| "max_tokens": self.max_tokens, | ||
| "max_iterations": self.max_iterations, | ||
| "grounded_from_topics": self.grounded_from_topics, | ||
| "temperature": self.temperature, | ||
| "seed": self.seed, | ||
| } | ||
| # A session run and a single-turn run answer different questions of the model (the | ||
| # history grows), so the pairing checks must be able to tell them apart. | ||
| if self.session_turns is not None: | ||
| config["session_turns"] = self.session_turns | ||
| config["turn_reminder"] = self.turn_reminder | ||
| return config | ||
|
|
||
| def run( | ||
| self, | ||
|
|
@@ -200,6 +230,12 @@ def run( | |
| ) -> List[Dict[str, Any]]: | ||
| """Answer every question under every arm. Returns one record per question.""" | ||
| n = len(questions) | ||
| if self.session_turns is not None: | ||
| # Before any answer is paid for: a question short of follow-ups fails the run. | ||
| for i, q in enumerate(questions): | ||
| qid = question_id(q) | ||
| session_turns_of(q, question_text_of(q), self.session_turns, | ||
| label=f"#{i}" if qid is None else str(qid)) | ||
| results: Dict[int, Dict[str, Any]] = {} | ||
| lock = threading.Lock() | ||
| done = [0] | ||
|
|
@@ -230,11 +266,25 @@ def _answer_question( | |
| if qid is None: | ||
| qid = "unknown_q" | ||
|
|
||
| turns = None | ||
| if self.session_turns is not None: | ||
| turns = session_turns_of(q, question_text, self.session_turns, label=qid) | ||
|
|
||
| arms: Dict[str, Any] = {} | ||
| for treatment in self.treatments: | ||
| arms[treatment.name] = self._answer_one_arm( | ||
| treatment, question_text, library_name, library_id, qid | ||
| if turns is None: | ||
| arms[treatment.name] = self._answer_one_arm( | ||
| treatment, question_text, library_name, library_id, qid | ||
| ) | ||
| continue | ||
| arms[treatment.name] = self._answer_session_arm( | ||
| treatment, turns, library_name, library_id, qid | ||
| ) | ||
| if self.turn_reminder is not None: | ||
| arms[treatment.name + REMINDER_ARM_SUFFIX] = self._answer_session_arm( | ||
| treatment, turns, library_name, library_id, qid, | ||
| reminder=self.turn_reminder, | ||
| ) | ||
|
|
||
| rec = { | ||
| "question_id": qid, | ||
|
|
@@ -245,6 +295,8 @@ def _answer_question( | |
| "persona": q.get("persona"), | ||
| "arms": arms, | ||
| } | ||
| if turns is not None: | ||
| rec["session_turns"] = len(turns) | ||
| # Selection label: present-and-None means "no skill should load", which is | ||
| # why presence is tested rather than truthiness. | ||
| if "expected_skill" in q: | ||
|
|
@@ -365,6 +417,109 @@ def _answer_one_arm( | |
| "elapsed_sec": round(time.time() - t0, 2), | ||
| } | ||
|
|
||
| def _answer_session_arm( | ||
| self, | ||
| treatment: Treatment, | ||
| turns: List[str], | ||
| library_name: str, | ||
| library_id: Optional[str], | ||
| question_id: str, | ||
| reminder: Optional[str] = None, | ||
| ) -> Dict[str, Any]: | ||
| """Answer one arm's multi-turn session; see :mod:`dokimi.eval.session`.""" | ||
| t0 = time.time() | ||
| try: | ||
| cfg = treatment.prepare(turns[0], library_name, library_id) | ||
| except Exception as exc: | ||
| logger.exception("Arm %s prepare() failed", treatment.name) | ||
| return {"error": f"prepare_failed: {exc}", "answer": None, | ||
| "metadata": _static_arm_kind(treatment)} | ||
|
|
||
| from dokimi.plugins import take_output_shapers | ||
| shapers, cfg_metadata = take_output_shapers(cfg) | ||
| if cfg.is_agentic: | ||
| return {"error": "session_unsupported: an agentic arm (tools) is answered in one " | ||
| "turn only", "answer": None, "metadata": cfg_metadata} | ||
|
|
||
| # The opening message is the single-turn prompt, context included, so turn 1 of | ||
| # a session is the answer the plain run would have given. | ||
| if cfg.has_context: | ||
| context = join_injected(cfg.injected_context) | ||
| opening = (CONTEXT_PROMPT_PREFIX.format(context=context[:MAX_CONTEXT_CHARS]) | ||
| + CONTEXT_PROMPT_SUFFIX.format(question=turns[0])) | ||
| else: | ||
| opening = ANSWER_PROMPT_PLAIN.format(question=turns[0]) | ||
|
|
||
| session = run_session( | ||
| model=self.model, provider=self.provider, api_key=self.api_key, | ||
| opening=opening, follow_ups=turns[1:], system=cfg.system_prompt, | ||
| max_tokens=self.max_tokens, temperature=self.temperature, seed=self.seed, | ||
| reminder=reminder, | ||
| ) | ||
| session_block = { | ||
| "turns_requested": len(turns), | ||
| "turns": session["turns"], | ||
| "stopped_reason": session["stopped_reason"], | ||
| "reminder": reminder is not None, | ||
| } | ||
| if session["error"] is not None: | ||
| logger.error("Arm %s session failed for %s: %s", treatment.name, | ||
| question_id, session["error"]) | ||
| return {"error": f"generation_failed: {session['error']}", "answer": None, | ||
| **self._session_spend(session), | ||
| "session": session_block, "metadata": cfg_metadata} | ||
|
|
||
| if session["stopped_reason"] == "empty_reply": | ||
| # The session ended short of the turns asked for. Recording it as a success | ||
| # would mix 2-turn and 3-turn sessions in one arm's totals; the usage stays. | ||
| error = f"empty_reply: turn {len(session['turns']) - 1}" | ||
| logger.error("Arm %s session stopped for %s: %s", treatment.name, question_id, error) | ||
| return {"error": error, "answer": None, **self._session_spend(session), | ||
| "session": session_block, "metadata": cfg_metadata} | ||
|
antonwolfy marked this conversation as resolved.
|
||
|
|
||
| raw_answer = session["answer"] | ||
| final_text = self._apply_shapers(shapers, raw_answer) | ||
| usage = session["usage"] | ||
| return { | ||
| "answer": final_text, | ||
| "raw_answer": raw_answer if shapers else None, | ||
| "model": self.model, | ||
| "harness": self.harness, | ||
| "plugin_set": self.plugin_set["plugin_set"], | ||
| "plugin_set_id": self.plugin_set["plugin_set_id"], | ||
| "used_system_prompt": bool(cfg.system_prompt), | ||
| "context_chunks": [ | ||
| { | ||
| "source": c.get("source", "unknown"), | ||
| "snippet": (c["content"][:200] + "…") if len(c["content"]) > 200 else c["content"], | ||
| "relevance_score": c.get("relevance_score"), | ||
| } | ||
| for c in cfg.injected_context | ||
| ], | ||
| # Whole-session totals, so the cost and token rollups read this arm like any other. | ||
| "token_usage": usage.as_token_usage_dict(), | ||
| "metrics": usage.as_metrics_dict( | ||
| answer_chars=len(raw_answer or ""), final_chars=len(final_text or "")), | ||
| "session": session_block, | ||
| "metadata": cfg_metadata, | ||
| "elapsed_sec": round(time.time() - t0, 2), | ||
| } | ||
|
|
||
| @staticmethod | ||
| def _session_spend(session: Dict[str, Any]) -> Dict[str, Any]: | ||
| """Token usage and metrics of the turns a failed session did complete. | ||
|
|
||
| The calls were paid for, so the cost rollup must count them; an arm without | ||
| ``metrics`` is skipped by it. No answer-length keys are written: a failed | ||
| session has no answer, and plugin-delta averages those keys over every arm | ||
| that has a ``metrics`` block. | ||
| """ | ||
| if not session["turns"]: | ||
| return {} | ||
| usage = session["usage"] | ||
| return {"token_usage": usage.as_token_usage_dict(), | ||
|
Comment on lines
+519
to
+520
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A failed session records
Repro ( Suggestion:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 00204bf: |
||
| "metrics": usage.as_metrics_dict()} | ||
|
|
||
| @staticmethod | ||
| def _apply_shapers(shapers, answer_text): | ||
| """Run output shapers over a raw answer (no-op when there are none).""" | ||
|
|
@@ -497,6 +652,7 @@ def _score(rec: Record) -> Dict[str, Any]: | |
| recs = as_records(records, source="answers", kind="answers") | ||
| # Before any judge call: refuse, do not grade against "". | ||
| require_question_texts(r.raw for r in recs) | ||
| _refuse_session_records(recs) | ||
| n = len(recs) | ||
| out: Dict[int, Dict[str, Any]] = {} | ||
| with ThreadPoolExecutor(max_workers=concurrency) as pool: | ||
|
|
@@ -539,6 +695,8 @@ def build_output( | |
| "agentic_usage_summary": self._summarize_agentic_usage(records), | ||
| "skill_selection_summary": summarize_skill_selection(records), | ||
| } | ||
| if self.session_turns is not None: | ||
| out["session"] = {"turns": self.session_turns, "turn_reminder": self.turn_reminder} | ||
|
antonwolfy marked this conversation as resolved.
|
||
| # One copy of each judge context the arm records cite, written whether or | ||
| # not the run was judged, so an answers-only artifact can be judged later. | ||
| materials = cite_judge_materials(records, self.judge_context) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The opening turn of a context-bearing arm is not tested against the first question.
Mutation check:
CONTEXT_PROMPT_SUFFIX.format(question=turns[-1])here passes all 25 tests.test_opening_is_prepared_from_the_first_turn_not_the_lastcoversprepare(turns[0])and the plainANSWER_PROMPT_PLAINbranch (both mutants fail), but not this branch. With the mutant, a skill or context arm opens a 3-turn session with the last follow-up and the history is in the wrong order, for those arms only.Suggestion: in the context-cap test (
test_the_opening_turn_caps_injected_context_as_the_plain_run_does), also assert that the opening ends with the first question and does not contain the last follow-up.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 00204bf:
test_the_opening_turn_caps_injected_context_as_the_plain_run_doesnow also asserts the opening contains the first question and none of the follow-ups. Theturns[-1]mutant of the context branch fails it.