diff --git a/docs/v6/reference/agents.mdx b/docs/v6/reference/agents.mdx index 7a8994fa7..1f32b2961 100644 --- a/docs/v6/reference/agents.mdx +++ b/docs/v6/reference/agents.mdx @@ -64,7 +64,8 @@ agent = ClaudeAgent(ClaudeConfig(model="claude-sonnet-4-5", max_steps=30)) Each config lives in `hud.agents.types`. `OpenAIChatAgent` speaks the OpenAI Chat Completions API, so it points at any compatible server (vLLM, a local model) via `base_url`; `ClaudeSDKAgent` runs the `claude` CLI over an `ssh` capability, against the env's filesystem. Every knob (`model`, `max_steps`, -`system_prompt`, `citations_enabled`) lives on the config; `__call__(run)` takes only the run. +`system_prompt`, `citations_enabled`, `stop_on`) lives on the config; `__call__(run)` +takes only the run. ## create_agent {#create-agent} diff --git a/docs/v6/reference/types.mdx b/docs/v6/reference/types.mdx index 9b30ed587..21c77c91c 100644 --- a/docs/v6/reference/types.mdx +++ b/docs/v6/reference/types.mdx @@ -84,6 +84,7 @@ unit of training data. Every recorded step also streams to the platform as one s |-------|------|-------------| | `steps` | `list[Step]` | The ordered trajectory. | | `status` | `"completed" \| "error" \| "cancelled" \| None` | How the run ended (`trace.is_error` reads it). | +| `stop_reason` | `"done" \| "max_steps" \| "length" \| "timeout" \| "malformed_tool_call" \| None` | Why the rollout stopped (`trace.is_truncated` reads it: anything but `"done"` means a limit cut it off). | | `content` | `str \| None` | The final answer (graded). | | `trace_id` | `str \| None` | Keys server-side trajectories. | diff --git a/hud/agents/openai/agent.py b/hud/agents/openai/agent.py index 6b504b2fa..ed70e31ee 100644 --- a/hud/agents/openai/agent.py +++ b/hud/agents/openai/agent.py @@ -270,12 +270,17 @@ async def get_response( "".join(summary.text for summary in item.summary), ) case "function_call": - tool_calls.append( - MCPToolCall( - name=item.name or "", - arguments=json.loads(item.arguments), - id=item.call_id, + fn_arguments: dict[str, Any] | str + try: + parsed = json.loads(item.arguments or "{}") + fn_arguments = ( + cast("dict[str, Any]", parsed) if isinstance(parsed, dict) else {} ) + except json.JSONDecodeError as e: + logger.warning("Malformed tool-call arguments for %s: %s", item.name, e) + fn_arguments = item.arguments + tool_calls.append( + MCPToolCall(name=item.name or "", arguments=fn_arguments, id=item.call_id) ) case "computer_call": if item.actions: @@ -313,12 +318,16 @@ async def get_response( completion_tokens=response.usage.output_tokens, cached_tokens=response.usage.input_tokens_details.cached_tokens, ) + # The Responses API has no finish_reason; truncation surfaces as + # incomplete_details.reason ("max_output_tokens" / "content_filter"). + incomplete = response.incomplete_details return AgentStep( content="".join(text_chunks), reasoning="\n".join(reasoning_chunks) if reasoning_chunks else None, citations=citations, tool_calls=tool_calls, done=not tool_calls, + finish_reason=incomplete.reason if incomplete is not None else None, model=response.model, usage=usage, ) diff --git a/hud/agents/openai_compatible/agent.py b/hud/agents/openai_compatible/agent.py index 9887a0861..c2aba7641 100644 --- a/hud/agents/openai_compatible/agent.py +++ b/hud/agents/openai_compatible/agent.py @@ -170,6 +170,23 @@ async def get_response( choice = response.choices[0] message = choice.message function_calls = [tc for tc in message.tool_calls or [] if tc.type == "function"] + tool_calls: list[MCPToolCall] = [] + recorded_calls: list[dict[str, Any]] = [] + for tc in function_calls: + name, raw = tc.function.name, tc.function.arguments + arguments: dict[str, Any] | str + try: + parsed = json.loads(raw or "{}") + arguments = cast("dict[str, Any]", parsed) if isinstance(parsed, dict) else {} + recorded = raw + except json.JSONDecodeError as e: + logger.warning("Malformed tool-call arguments for %s: %s", name, e) + arguments = raw + recorded = "{}" # replayed history must stay parseable + tool_calls.append(MCPToolCall(id=tc.id, name=name, arguments=arguments)) + recorded_calls.append( + {"id": tc.id, "type": "function", "function": {"name": name, "arguments": recorded}} + ) assistant_message = message.model_dump(exclude_none=True) reasoning_content = getattr(message, "reasoning_content", None) @@ -181,17 +198,7 @@ async def get_response( if value := getattr(message, field_name, None): assistant_message[field_name] = value if function_calls: - assistant_message["tool_calls"] = [ - { - "id": tc.id, - "type": "function", - "function": { - "name": tc.function.name, - "arguments": tc.function.arguments, - }, - } - for tc in function_calls - ] + assistant_message["tool_calls"] = recorded_calls messages.append(cast("ChatCompletionMessageParam", assistant_message)) sample: Sample | None = None @@ -219,15 +226,6 @@ async def get_response( chat_state.continuation_token_ids = None chat_state.continuation_message_count = None - tool_calls: list[MCPToolCall] = [] - for tc in function_calls: - provider_name = tc.function.name - raw_args = json.loads(tc.function.arguments or "{}") - arguments = cast("dict[str, Any]", raw_args) if isinstance(raw_args, dict) else {} - tool_calls.append( - MCPToolCall(id=tc.id, name=provider_name, arguments=arguments), - ) - usage: Usage | None = None if response.usage is not None: details = response.usage.prompt_tokens_details diff --git a/hud/agents/tests/test_openai_agent.py b/hud/agents/tests/test_openai_agent.py index ce424e26f..b7dd88297 100644 --- a/hud/agents/tests/test_openai_agent.py +++ b/hud/agents/tests/test_openai_agent.py @@ -39,9 +39,17 @@ def test_format_message_shapes_user_text() -> None: assert msg["role"] == "user" -def _api_response(id: str, output: list[Any], usage: Any = None) -> Any: +def _api_response( + id: str, output: list[Any], usage: Any = None, incomplete_details: Any = None +) -> Any: """A fake Responses-API payload: output items plus the response envelope.""" - return SimpleNamespace(id=id, output=output, model="gpt-test-v1", usage=usage) + return SimpleNamespace( + id=id, + output=output, + model="gpt-test-v1", + usage=usage, + incomplete_details=incomplete_details, + ) async def test_get_response_parses_text_and_function_call() -> None: @@ -92,6 +100,19 @@ async def test_get_response_done_when_no_tool_calls() -> None: assert result.done is True assert result.tool_calls == [] assert result.usage is None # provider omitted usage + assert result.finish_reason is None + + +async def test_get_response_surfaces_token_cap_truncation() -> None: + # Responses has no finish_reason; incomplete_details.reason carries the cap. + response = _api_response( + "resp_3", [], incomplete_details=SimpleNamespace(reason="max_output_tokens") + ) + agent = _agent(response) + state = OpenAIRunState(messages=[agent._format_message("user", "hi")]) + + result = await agent.get_response(state) + assert result.finish_reason == "max_output_tokens" async def test_get_response_short_circuits_on_consumed_messages() -> None: diff --git a/hud/agents/tests/test_openai_compatible_agent.py b/hud/agents/tests/test_openai_compatible_agent.py index 3f7f1d122..2d0a7797b 100644 --- a/hud/agents/tests/test_openai_compatible_agent.py +++ b/hud/agents/tests/test_openai_compatible_agent.py @@ -32,11 +32,21 @@ def _agent(response: Any, error: Exception | None = None) -> OpenAIChatAgent: def _response(content: str, tool_calls: list[Any]) -> Any: + dump: dict[str, Any] = {"role": "assistant", "content": content} + if tool_calls: + dump["tool_calls"] = [ + { + "id": tc.id, + "type": "function", + "function": {"name": tc.function.name, "arguments": tc.function.arguments}, + } + for tc in tool_calls + ] message = SimpleNamespace( content=content, tool_calls=tool_calls, refusal=None, - model_dump=lambda exclude_none=True: {"role": "assistant", "content": content}, + model_dump=lambda exclude_none=True: dump, ) choice = SimpleNamespace(message=message, finish_reason="stop", logprobs=None) return SimpleNamespace( @@ -81,3 +91,23 @@ async def test_get_response_error_path() -> None: result = await agent.get_response(_state(agent)) assert result.done is True assert result.error is not None and "boom" in result.error + + +async def test_get_response_malformed_tool_args() -> None: + """Unparseable arguments (e.g. truncated at the token limit) yield a call carrying + the raw string, and the recorded message replays "{}" so history stays parseable.""" + tc = SimpleNamespace( + type="function", + id="c1", + function=SimpleNamespace(name="bash", arguments='{"command": "'), + ) + agent = _agent(_response("", [tc])) + state = _state(agent) + result = await agent.get_response(state) + assert result.error is None + assert result.done is False + (call,) = result.tool_calls + assert call.id == "c1" + assert call.arguments == '{"command": "' + recorded = state.messages[-1] + assert recorded["tool_calls"][0]["function"]["arguments"] == "{}" diff --git a/hud/agents/tests/test_tool_agent.py b/hud/agents/tests/test_tool_agent.py index c79dbf070..8e12a59af 100644 --- a/hud/agents/tests/test_tool_agent.py +++ b/hud/agents/tests/test_tool_agent.py @@ -33,8 +33,8 @@ def record(self, step: Step) -> None: class DictAgent(ToolAgent[_Msg, AgentConfig]): """Minimal concrete ToolAgent over plain-dict messages.""" - def __init__(self, turns: list[AgentStep]) -> None: - self.config = AgentConfig(model="test-model") + def __init__(self, turns: list[AgentStep], **config: Any) -> None: + self.config = AgentConfig(model="test-model", **config) self._turns = list(turns) async def _initialize_state(self, *, prompt: Any) -> RunState[_Msg]: @@ -85,6 +85,18 @@ async def test_dispatch_unknown_tool_returns_error_result() -> None: assert result.isError is True +async def test_dispatch_unparsed_arguments_returns_error_result() -> None: + # A call whose provider arguments never parsed is answered, not executed. + agent = DictAgent([]) + call = MCPToolCall(name="bash", arguments='{"command": "') + result = await agent._dispatch_call(call, RunState()) + assert result.isError is True + content = result.content[0] + assert isinstance(content, mcp_types.TextContent) + assert "not executed" in content.text + assert '{"command": "' in content.text # the raw prefix re-anchors the model + + async def test_loop_finishes_on_done_response() -> None: agent = DictAgent([AgentStep(content="final answer", done=True)]) run = _FakeRun() @@ -94,6 +106,8 @@ async def test_loop_finishes_on_done_response() -> None: assert run.trace.status == "completed" assert run.trace.content == "final answer" assert run.trace.is_error is False + assert run.trace.stop_reason == "done" + assert run.trace.is_truncated is False # The agent turn was recorded directly, with loop-stamped fallbacks. (step,) = run.trace.steps assert isinstance(step, AgentStep) @@ -139,6 +153,90 @@ async def test_loop_max_steps_is_normal_termination() -> None: assert run.trace.is_error is False assert run.trace.status == "completed" - assert run.trace.extra.get("stop_reason") == "max_steps" + assert run.trace.stop_reason == "max_steps" + assert run.trace.is_truncated is True # No synthetic error step — the trajectory ends on the real agent/tool steps. assert all(step.source != "system" for step in run.trace.steps) + + +async def test_loop_marks_length_finish_as_truncated() -> None: + # A final turn cut off at the provider token cap (e.g. mid-tool-call) ends the + # rollout normally but is a truncation, not a natural finish — across every + # provider's finish-reason vocabulary. + for finish_reason in ("length", "max_output_tokens", "max_tokens", "MAX_TOKENS"): + agent = DictAgent([AgentStep(content="partial", done=True, finish_reason=finish_reason)]) + run = _FakeRun() + + await agent._loop(run, RunState(), max_steps=3) # type: ignore[arg-type] + + assert run.trace.status == "completed" + assert run.trace.stop_reason == "length" + assert run.trace.is_truncated is True + + +async def test_loop_answers_malformed_call_by_default() -> None: + # Default "retry": the malformed call gets an error result and the loop continues. + agent = DictAgent( + [ + AgentStep( + content="", + done=False, + tool_calls=[MCPToolCall(name="bash", arguments='{"command": "')], + ), + AgentStep(content="recovered", done=True), + ] + ) + run = _FakeRun() + + await agent._loop(run, RunState(), max_steps=3) # type: ignore[arg-type] + + assert run.trace.content == "recovered" + tool_step = run.trace.steps[1] + assert isinstance(tool_step, ToolStep) + assert tool_step.result is not None + assert tool_step.result.isError is True + + +async def test_loop_stops_on_malformed_call_when_configured() -> None: + # The rollout ends at the malformed-call turn with nothing dispatched, and the + # fired condition is the recorded stop reason. + agent = DictAgent( + [ + AgentStep( + content="", + done=False, + tool_calls=[MCPToolCall(name="bash", arguments='{"command": "')], + ) + ], + stop_on={"malformed_tool_call"}, + ) + run = _FakeRun() + + await agent._loop(run, RunState(), max_steps=3) # type: ignore[arg-type] + + assert run.trace.status == "completed" + assert run.trace.stop_reason == "malformed_tool_call" + assert run.trace.is_truncated is True + assert all(not isinstance(step, ToolStep) for step in run.trace.steps) + + +async def test_loop_stops_on_length_when_configured() -> None: + # A token-capped turn ends the rollout even when its tool calls parsed. + agent = DictAgent( + [ + AgentStep( + content="", + done=False, + finish_reason="length", + tool_calls=[MCPToolCall(name="bash", arguments={"command": "ls"})], + ) + ], + stop_on={"length", "malformed_tool_call"}, + ) + run = _FakeRun() + + await agent._loop(run, RunState(), max_steps=3) # type: ignore[arg-type] + + assert run.trace.stop_reason == "length" + assert run.trace.is_truncated is True + assert all(not isinstance(step, ToolStep) for step in run.trace.steps) diff --git a/hud/agents/tool_agent.py b/hud/agents/tool_agent.py index 9f5448ade..fb538248d 100644 --- a/hud/agents/tool_agent.py +++ b/hud/agents/tool_agent.py @@ -30,7 +30,7 @@ def _format_result(self, call, result) -> BetaMessageParam | None: ... from hud.agents.tools.base import AgentTool from hud.agents.types import AgentStep, ToolStep from hud.capabilities import MCPClient -from hud.types import AgentType, MCPToolCall, MCPToolResult, Step +from hud.types import AgentType, MCPToolCall, MCPToolResult, Step, StopCondition from hud.utils.time import now_iso if TYPE_CHECKING: @@ -40,6 +40,10 @@ def _format_result(self, call, result) -> BetaMessageParam | None: ... logger = logging.getLogger(__name__) +# Output-token-cap finish reasons: OpenAI chat "length", Responses "max_output_tokens", +# Claude "max_tokens", Gemini "MAX_TOKENS". +TRUNCATION_FINISH_REASONS = frozenset({"length", "max_output_tokens", "max_tokens", "MAX_TOKENS"}) + MessageT = TypeVar("MessageT") ConfigT = TypeVar("ConfigT", bound="AgentConfig") @@ -183,6 +187,7 @@ async def _loop( try: step: AgentStep | None = None hit_max = False + stopped: StopCondition | None = None for turn in range(1, max_steps + 1): logger.info("step %d/%d", turn, max_steps) @@ -212,6 +217,9 @@ async def _loop( continue break + if (stopped := self._stop_condition(step)) is not None: + break + for call in step.tool_calls: call_started_at = now_iso() result = await self._dispatch_call(call, state) @@ -229,7 +237,14 @@ async def _loop( trace.content = step.content if step else None trace.status = "error" if step is not None and step.error else "completed" - trace.extra["stop_reason"] = "max_steps" if hit_max else "done" + if stopped is not None: + trace.stop_reason = stopped + elif hit_max: + trace.stop_reason = "max_steps" + elif step is not None and step.finish_reason in TRUNCATION_FINISH_REASONS: + trace.stop_reason = "length" + else: + trace.stop_reason = "done" except (TimeoutError, asyncio.CancelledError, KeyboardInterrupt): raise except Exception as exc: @@ -237,18 +252,43 @@ async def _loop( trace.status = "error" run.record(Step(source="system", error=str(exc))) + def _stop_condition(self, step: AgentStep) -> StopCondition | None: + """The first configured stop condition this turn trips, if any.""" + stop_on = self.config.stop_on + if "length" in stop_on and step.finish_reason in TRUNCATION_FINISH_REASONS: + return "length" + if "malformed_tool_call" in stop_on and any( + isinstance(call.arguments, str) for call in step.tool_calls + ): + return "malformed_tool_call" + return None + async def _dispatch_call( self, call: MCPToolCall, state: RunState[MessageT], ) -> MCPToolResult: + if isinstance(call.arguments, str): + return MCPToolResult( + content=[ + mcp_types.TextContent( + type="text", + text=( + f"the arguments for this {call.name!r} call arrived incomplete " + f"(cut off mid-generation or invalid JSON), so it was not executed. " + f"Received: {call.arguments[:200]!r}. Re-issue the call in full." + ), + ) + ], + isError=True, + ) tool = state.tools.get(call.name) if tool is None: return MCPToolResult( content=[mcp_types.TextContent(type="text", text=f"unknown tool: {call.name!r}")], isError=True, ) - args = call.arguments if isinstance(call.arguments, dict) else {} + args = call.arguments or {} try: return await tool.execute(args) except (TimeoutError, asyncio.CancelledError): diff --git a/hud/agents/types.py b/hud/agents/types.py index c1bac1383..8bafff7e9 100644 --- a/hud/agents/types.py +++ b/hud/agents/types.py @@ -35,6 +35,7 @@ RobotStepSource, Step, StepSource, + StopCondition, Trace, ) from hud.utils.serialization import json_safe_value @@ -50,6 +51,9 @@ class AgentConfig(BaseModel): max_steps: int = 10 system_prompt: str | None = None citations_enabled: bool = False + #: Conditions that end the rollout instead of being answered with an error + #: result the model can react to; training configs typically stop on both. + stop_on: set[StopCondition] = Field(default_factory=set[StopCondition]) hosted_tools: list[HostedTool[object]] = Field(default_factory=list[HostedTool[object]]) model_name: str = "Agent" diff --git a/hud/eval/job.py b/hud/eval/job.py index 316459cbf..985eb2d96 100644 --- a/hud/eval/job.py +++ b/hud/eval/job.py @@ -133,8 +133,9 @@ async def trace_exit(run: Run) -> None: # reports a trace-level error. "error": run.trace.error if run.trace.is_error else None, "evaluation_result": run.evaluation or None, - # Trajectory metadata (e.g. ``stop_reason``: max_steps vs done) the - # platform stores on the trace for display; never load-bearing. + "stop_reason": run.trace.stop_reason, + # Trajectory metadata the platform stores on the trace for display; + # never load-bearing. "metadata": run.trace.extra or None, }, ) diff --git a/hud/eval/run.py b/hud/eval/run.py index c28edcbbd..5949786bd 100644 --- a/hud/eval/run.py +++ b/hud/eval/run.py @@ -375,7 +375,7 @@ async def _drive() -> None: "rollout agent loop timed out after %.0fs; grading partial", rollout_timeout, ) - run.trace.extra["stop_reason"] = "timeout" + run.trace.stop_reason = "timeout" run.record( Step( source="system", diff --git a/hud/eval/tests/test_job.py b/hud/eval/tests/test_job.py index 788d2514c..9b61df8b5 100644 --- a/hud/eval/tests/test_job.py +++ b/hud/eval/tests/test_job.py @@ -46,13 +46,16 @@ def _run_with(trace_id: str, *, extra: dict[str, Any]) -> Run: return run -async def test_trace_exit_propagates_stop_reason_as_metadata(recorder: _Recorder) -> None: - await job_mod.trace_exit(_run_with("abc", extra={"stop_reason": "max_steps"})) +async def test_trace_exit_propagates_stop_reason(recorder: _Recorder) -> None: + run = _run_with("abc", extra={}) + run.trace.stop_reason = "max_steps" + await job_mod.trace_exit(run) assert len(recorder.calls) == 1 path, body = recorder.calls[0] assert path == "/trace/abc/exit" - assert body["metadata"] == {"stop_reason": "max_steps"} + assert body["stop_reason"] == "max_steps" + assert "metadata" not in body async def test_trace_exit_omits_metadata_when_extra_empty(recorder: _Recorder) -> None: diff --git a/hud/eval/tests/test_rollout.py b/hud/eval/tests/test_rollout.py index 8729ffe8e..0d8bb5166 100644 --- a/hud/eval/tests/test_rollout.py +++ b/hud/eval/tests/test_rollout.py @@ -332,7 +332,7 @@ async def add(a: int, b: int): # recorded, so it is graded rather than discarded as a zero-reward cancel. assert run.reward == 1.0 assert run.trace.status == "completed" - assert run.trace.extra.get("stop_reason") == "timeout" + assert run.trace.stop_reason == "timeout" assert run.trace_id is not None diff --git a/hud/types.py b/hud/types.py index 881f93a7f..4ab3f1327 100644 --- a/hud/types.py +++ b/hud/types.py @@ -22,7 +22,7 @@ import json import uuid from enum import Enum -from typing import TYPE_CHECKING, ClassVar, Literal, TypeAlias, TypeVar, cast +from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeAlias, TypeVar, cast import mcp.types as types from mcp.types import CallToolRequestParams, CallToolResult @@ -134,6 +134,9 @@ class MCPToolCall(CallToolRequestParams): id: str = Field(default_factory=lambda: str(uuid.uuid4())) # Unique identifier for reference annotation: str | None = None # Optional explanation of why this action is taken provider_name: str | None = None # Original provider tool name when it differs from MCP name + #: Widened to admit the raw provider string when it didn't parse as JSON; + #: a str is never executed — dispatch answers it with an error result. + arguments: dict[str, Any] | str | None = None def __str__(self) -> str: """Format tool call as plain text.""" @@ -296,6 +299,13 @@ def emit(self, *, trace_id: str | None = None) -> None: TraceStatus: TypeAlias = Literal["completed", "error", "cancelled"] +#: Why the rollout stopped; anything but "done" means a limit cut it off. +StopReason: TypeAlias = Literal["done", "max_steps", "length", "timeout", "malformed_tool_call"] + +#: The configurable subset of stop reasons (``AgentConfig.stop_on``): policy +#: conditions the loop may either stop on or answer with an error result. +StopCondition: TypeAlias = Literal["length", "malformed_tool_call"] + class Trace(BaseModel): """The agent's trajectory for one rollout — ordered ``Step``s that ship as spans. @@ -320,6 +330,7 @@ class Trace(BaseModel): steps: list[SerializeAsAny[Step]] = Field(default_factory=list[Step]) status: TraceStatus | None = None + stop_reason: StopReason | None = None content: str | None = Field(default=None) # Trajectory metadata that has no structured home (provider session info, @@ -359,6 +370,11 @@ def collect(self, get: Callable[[Step], T | None]) -> list[T]: def is_error(self) -> bool: return self.status == "error" + @property + def is_truncated(self) -> bool: + """Whether a limit (step budget, token cap, timeout) cut the rollout off.""" + return self.stop_reason is not None and self.stop_reason != "done" + @property def error(self) -> str | None: """The most recent step error, if any (errors live on steps).""" @@ -398,6 +414,8 @@ def __len__(self) -> int: "MCPToolResult", "Step", "StepSource", + "StopCondition", + "StopReason", "TaskCall", "Trace", "TraceStatus", diff --git a/hud/utils/hud_console.py b/hud/utils/hud_console.py index 3a20687ca..2e3972dc7 100644 --- a/hud/utils/hud_console.py +++ b/hud/utils/hud_console.py @@ -390,7 +390,7 @@ def _cancel(event: Any) -> None: return result - def format_tool_call(self, name: str, arguments: dict[str, Any] | None = None) -> str: + def format_tool_call(self, name: str, arguments: dict[str, Any] | str | None = None) -> str: """Format a tool call in compact HUD style. Args: